exyezed / Spotify Enhancer (Track Downloader)

// ==UserScript==
// @name         Spotify Enhancer (Track Downloader)
// @description  Integrate a download button for tracks on Spotify Web to download audio at 320kbps
// @icon         https://raw.githubusercontent.com/exyezed/spotify-enhancer/refs/heads/main/extras/spotify-enhancer.png
// @version      1.6
// @author       exyezed
// @namespace    https://github.com/exyezed/spotify-enhancer/
// @supportURL   https://github.com/exyezed/spotify-enhancer/issues
// @license      MIT
// @match        *://open.spotify.com/*
// @grant        GM_xmlhttpRequest
// @connect      api.spotifydown.com
// ==/UserScript==

(function() {
    'use strict';

    const API_REQUEST_HEADERS = {
        'Host': 'api.spotifydown.com',
        'Referer': 'https://spotifydown.com/',
        'Origin': 'https://spotifydown.com',
    };

    const RETRY_CONFIG = {
        initialDelay: 1000,
        maxDelay: 5000,
        backoffFactor: 2
    };

    function isOtherDownloaderInstalled() {
        return document.querySelector('.btn') !== null;
    }

    function getStyles() {
        const otherInstalled = isOtherDownloaderInstalled();
        const marginLeft = otherInstalled ? '90px' : '50px';
        const buttonMarginRight = otherInstalled ? '10px' : '10px';
        const buttonSpacing = otherInstalled ? '5px' : '0';

        return `
        [role='grid'] {
            margin-left: ${marginLeft} !important;
        }
        [data-testid='tracklist-row'] {
            position: relative;
        }
        [role="presentation"] > * {
            contain: unset;
        }
        .btn-320 {
            width: 36px;
            height: 36px;
            border-radius: 50%;
            border: 0;
            background: linear-gradient(135deg, #00da5a, #008f3b);
            position: relative;
            cursor: pointer;
            transition: all 0.3s ease;
            box-shadow: 0 2px 5px rgba(0,0,0,0.2);
            display: flex;
            align-items: center;
            justify-content: center;
            margin-left: ${buttonSpacing};
        }

        .N7GZp8IuWPJvCPz_7dOg .btn-320 {
            width: 24px;
            height: 24px;
            position: absolute;
            top: 50%;
            transform: translateY(-50%);
            right: ${otherInstalled ? 'calc(100% + 50px)' : '100%'};
            margin-right: ${buttonMarginRight};
        }

        .N7GZp8IuWPJvCPz_7dOg .btn-320::after {
            width: 16px;
            height: 16px;
        }

        .btn-320::after {
            content: '';
            position: absolute;
            width: 24px;
            height: 24px;
            background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24"><g fill="none" stroke="%23ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="32" stroke-dashoffset="32" d="M12 3c4.97 0 9 4.03 9 9c0 4.97 -4.03 9 -9 9"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.4s" values="32;0"/><set fill="freeze" attributeName="stroke-dasharray" begin="0.8s" to="2 4"/></path><path stroke-dasharray="32" stroke-dashoffset="32" d="M12 21c-4.97 0 -9 -4.03 -9 -9c0 -4.97 4.03 -9 9 -9"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.4s" dur="0.4s" values="32;0"/></path><path stroke-dasharray="10" stroke-dashoffset="10" d="M12 8v7.5"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.8s" dur="0.2s" values="10;0"/></path><path stroke-dasharray="6" stroke-dashoffset="6" d="M12 15.5l3.5 -3.5M12 15.5l-3.5 -3.5"><animate fill="freeze" attributeName="stroke-dashoffset" begin="1s" dur="0.2s" values="6;0"/></path></g></svg>');
            background-position: center;
            background-repeat: no-repeat;
            background-size: 100%;
        }
        .btn-320:hover {
            transform: scale(1.1);
            box-shadow: 0 4px 8px rgba(0,0,0,0.3);
            background: linear-gradient(135deg, #00ff69, #00ab46);
        }
        .btn-320.loading::after {
            background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24"><g fill="none" stroke="%23ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="2 4" stroke-dashoffset="6" d="M12 3c4.97 0 9 4.03 9 9c0 4.97 -4.03 9 -9 9"><animate attributeName="stroke-dashoffset" dur="0.6s" repeatCount="indefinite" values="6;0"/></path><path stroke-dasharray="32" stroke-dashoffset="32" d="M12 21c-4.97 0 -9 -4.03 -9 -9c0 -4.97 4.03 -9 9 -9"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.1s" dur="0.4s" values="32;0"/></path><path stroke-dasharray="10" stroke-dashoffset="10" d="M12 8v7.5"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.5s" dur="0.2s" values="10;0"/></path><path stroke-dasharray="6" stroke-dashoffset="6" d="M12 15.5l3.5 -3.5M12 15.5l-3.5 -3.5"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.7s" dur="0.2s" values="6;0"/></path></g></svg>');
        }
        [data-testid='tracklist-row'] .btn-320 {
            position: absolute;
            top: 50%;
            transform: translateY(-50%);
            right: ${otherInstalled ? 'calc(100% + 50px)' : '100%'};
            margin-right: ${buttonMarginRight};
        }

        [data-testid='tracklist-row'].N7GZp8IuWPJvCPz_7dOg .btn-320 {
            right: ${otherInstalled ? 'calc(99% + 50px)' : '99%'};
        }
        `;
    }

    function updateStyles() {
        const styleElement = document.getElementById('spotify-enhancer-320-styles');
        if (styleElement) {
            styleElement.textContent = getStyles();
        }
    }

    function createStyleElement() {
        const style = document.createElement('style');
        style.id = 'spotify-enhancer-320-styles';
        style.textContent = getStyles();
        document.body.appendChild(style);
    }

    function observeBodyChanges() {
        const observer = new MutationObserver(() => {
            if (isOtherDownloaderInstalled()) {
                updateStyles();
                observer.disconnect();
            }
        });

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

    async function delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function attemptDownload(spotifyId) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: `https://api.spotifydown.com/download/${spotifyId}`,
                headers: API_REQUEST_HEADERS,
                onload: response => {
                    try {
                        const result = JSON.parse(response.responseText);
                        if (!result.success) {
                            reject(new Error(result.message || 'Download failed'));
                        } else {
                            resolve(result);
                        }
                    } catch (error) {
                        reject(new Error('Failed to parse response'));
                    }
                },
                onerror: reject
            });
        });
    }

    async function downloadWithRetry(spotifyId, trackInfo) {
        const button = this;
        button.classList.add('loading');

        let currentDelay = RETRY_CONFIG.initialDelay;
        let attempts = 0;

        while (true) {
            try {
                const response = await attemptDownload(spotifyId);

                const link = document.createElement('a');
                link.href = response.link;
                if (trackInfo) {
                    link.download = `${trackInfo.title} - ${trackInfo.artist}.mp3`;
                }
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);

                button.classList.remove('loading', 'retrying');
                return;
            } catch (error) {
                attempts++;
                console.log(`Attempt ${attempts} failed, retrying in ${currentDelay}ms...`);
                button.classList.add('retrying');

                await delay(currentDelay);

                currentDelay = Math.min(
                    currentDelay * RETRY_CONFIG.backoffFactor,
                    RETRY_CONFIG.maxDelay
                );
            }
        }
    }

    const downloadTrack = downloadWithRetry;

    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function getTrackInfoFromArtist(trackElement) {
        const titleElement = trackElement.querySelector('.encore-text.encore-text-body-medium');
        const artistElement = document.querySelector('span[data-testid="entityTitle"] h1');

        if (titleElement && artistElement) {
            return {
                title: titleElement.textContent.trim(),
                artist: artistElement.textContent.trim()
            };
        }
        return null;
    }

    function getTrackInfo(trackElement) {
        if (window.location.href.includes('/artist/')) {
            return null;
        }

        const titleElement = trackElement.querySelector('div[data-encore-id="text"][dir="auto"]');
        const artistElements = trackElement.querySelectorAll('a[href^="/artist"]');

        if (titleElement && artistElements.length > 0) {
            const artists = Array.from(artistElements)
                .map(el => el.textContent.trim())
                .join(', ');

            return {
                title: titleElement.textContent.trim(),
                artist: artists
            };
        }
        return null;
    }

    function createDownloadButton() {
        const button = document.createElement('button');
        button.className = 'btn-320';
        button.title = 'Download 320kbps';
        return button;
    }

    function addButtonToTrack(track) {
        if (!track.hasButton320) {
            const button = createDownloadButton();
            track.appendChild(button);
            track.hasButton320 = true;
            return button;
        }
        return null;
    }

    function animate() {
        const currentUrl = window.location.href;
        const urlParts = currentUrl.split('/');
        const type = urlParts[3];

        if (type === 'artist') {
            const tracks = document.querySelectorAll('[role="gridcell"]');
            tracks.forEach(track => {
                if (track.querySelector('.encore-text-body-medium') && !track.hasButton320) {
                    const button = addButtonToTrack(track);
                    if (button) {
                        button.onclick = async function() {
                            const trackLink = track.querySelector('a[href^="/track"]');
                            if (trackLink) {
                                const spotifyId = trackLink.href.split('/').pop().split('?')[0];
                                const trackInfo = getTrackInfoFromArtist(track);
                                downloadTrack.call(this, spotifyId, trackInfo);
                            }
                        };
                    }
                }
            });
        } else {
            const tracks = document.querySelectorAll('[data-testid="tracklist-row"]');
            tracks.forEach(track => {
                if (!track.hasButton320) {
                    const button = addButtonToTrack(track);
                    if (button) {
                        button.onclick = async function() {
                            const btn = track.querySelector('[data-testid="more-button"]');
                            btn.click();
                            await sleep(1);
                            const highlight = document.querySelector('#context-menu a[href*="highlight"]').href.match(/highlight=(.+)/)[1];
                            document.dispatchEvent(new MouseEvent('mousedown'));
                            const spotifyId = highlight.split(':')[2];
                            const trackInfo = getTrackInfo(track);
                            downloadTrack.call(this, spotifyId, trackInfo);
                        };
                    }
                }
            });
        }

        if (type === 'track') {
            const actionBarRow = document.querySelector('[data-testid="action-bar-row"]:last-of-type');
            if (actionBarRow && !actionBarRow.hasButton320) {
                const button = addButtonToTrack(actionBarRow);
                if (button) {
                    button.onclick = function() {
                        const id = urlParts[4].split('?')[0];
                        const titleElement = document.querySelector('h1');
                        const artistElement = document.querySelector('a[href^="/artist"]');
                        const trackInfo = titleElement && artistElement ? {
                            title: titleElement.textContent.trim(),
                            artist: artistElement.textContent.trim()
                        } : null;
                        downloadTrack.call(this, id, trackInfo);
                    };
                }
            }
        }
    }

    createStyleElement();
    observeBodyChanges();
    setInterval(animate, 1000);
})();

console.log("Spotify Enhancer (Track Downloader) is running");