Greasy Fork 支持简体中文。

Spotify Downloader

Adds convenient download buttons to Spotify tracks, allowing users to download music directly from the web.

// ==UserScript==
// @name         Spotify Downloader
// @description  Adds convenient download buttons to Spotify tracks, allowing users to download music directly from the web.
// @icon         https://www.google.com/s2/favicons?sz=64&domain=spotify.com
// @version      3.4
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/misc-scripts/
// @supportURL   https://github.com/afkarxyz/misc-scripts/issues
// @license      MIT
// @match        *://open.spotify.com/*
// @grant        GM_xmlhttpRequest
// ==/UserScript==

const PRIMARY_COLOR = '#00da5a';
const DEFAULT_COLOR = '#ffffff';

const BUTTON_GRADIENT = { start: PRIMARY_COLOR, end: '#008035' };

const style = document.createElement('style');
style.innerText = `
[role='grid'] {
    margin-left: 50px;
}
[data-testid="tracklist-row"] {
    position: relative;
}
[role="presentation"] > * {
    contain: unset;
}
.btn {
    width: 40px;
    height: 40px;
    border-radius: 50%;
    border: 0;
    position: relative;
    cursor: pointer;
    transition: all 0.2s ease;
    box-shadow: 0 2px 5px rgba(0,0,0,0.2);
    display: flex;
    align-items: center;
    justify-content: center;
}
.btn::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: 50%;
    height: 50%;
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
    transition: opacity 0.2s ease;
}
.btn .icon {
    position: absolute;
    width: 50%;
    height: 50%;
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
    transition: opacity 0.2s ease;
    opacity: 1;
}
.btn .loading-icon {
    position: absolute;
    width: 50%;
    height: 50%;
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
    transition: opacity 0.2s ease;
    opacity: 0;
    background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 256C0 114.9 114.1 .5 255.1 0C237.9 .5 224 14.6 224 32c0 17.7 14.3 32 32 32C150 64 64 150 64 256s86 192 192 192c69.7 0 130.7-37.1 164.5-92.6c-3 6.6-3.3 14.8-1 22.2c1.2 3.7 3 7.2 5.4 10.3c1.2 1.5 2.6 3 4.1 4.3c.8 .7 1.6 1.3 2.4 1.9c.4 .3 .8 .6 1.3 .9s.9 .6 1.3 .8c5 2.9 10.6 4.3 16 4.3c11 0 21.8-5.7 27.7-16c-44.3 76.5-127 128-221.7 128C114.6 512 0 397.4 0 256z" fill="%23ffffff"/><path class="fa-primary" d="M224 32c0-17.7 14.3-32 32-32C397.4 0 512 114.6 512 256c0 46.6-12.5 90.4-34.3 128c-8.8 15.3-28.4 20.5-43.7 11.7s-20.5-28.4-11.7-43.7c16.3-28.2 25.7-61 25.7-96c0-106-86-192-192-192c-17.7 0-32-14.3-32-32z" fill="%23ffffff"/></svg>');
}
.btn.loading .loading-icon {
    opacity: 1;
    animation: spin 1s linear infinite;
}
.btn.loading .icon {
    opacity: 0;
}
@keyframes spin {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
}
.N7GZp8IuWPJvCPz_7dOg .btn {
    width: 24px;
    height: 24px;
    margin-top: -12px !important;
}
.N7GZp8IuWPJvCPz_7dOg .btn::after {
    transform: translate(-50%, -50%) scale(0.85);
    width: 65%;
    height: 65%;
}
.N7GZp8IuWPJvCPz_7dOg .btn .icon,
.N7GZp8IuWPJvCPz_7dOg .btn .loading-icon {
    transform: scale(0.85);
}
.btn.track .icon {
    background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="%23ffffff" d="M369 217L241 345c-9.4 9.4-24.6 9.4-33.9 0L79 217c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l87 87L200 24c0-13.3 10.7-24 24-24s24 10.7 24 24l0 246.1 87-87c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9zM48 344l0 80c0 22.1 17.9 40 40 40l272 0c22.1 0 40-17.9 40-40l0-80c0-13.3 10.7-24 24-24s24 10.7 24 24l0 80c0 48.6-39.4 88-88 88L88 512c-48.6 0-88-39.4-88-88l0-80c0-13.3 10.7-24 24-24s24 10.7 24 24z"/></svg>');
}
.btn:hover {
    transform: scale(1.1);
    box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
[data-testid="tracklist-row"] .btn {
    position: absolute;
    top: 50%;
    right: 100%;
    margin-top: -20px;
    margin-right: 10px;
}
`;
document.body.appendChild(style);

function getTrackInfo(trackElement) {
    const titleElement = trackElement.querySelector('div[data-encore-id="text"].standalone-ellipsis-one-line');
    const artistElements = trackElement.querySelectorAll('span.encore-text-body-small[data-encore-id="text"] 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 getTrackInfoFromArtist(trackElement) {
    const titleElement = trackElement.querySelector('div[data-encore-id="text"].standalone-ellipsis-one-line');
    const artistElement = document.querySelector('span[data-testid="entityTitle"] h1');

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

function getNowPlayingTrackInfo() {
    const titleElement = document.querySelector('.FpKgwQJLYNDWugII3H4h, [data-testid="now-playing-widget"] .encore-text-body-small[data-encore-id="text"], .now-playing a[href^="/track"]');
    
    const artistElements = document.querySelectorAll('.jcGcOP.ggUwFI, [data-testid="now-playing-widget"] a[href^="/artist"], .now-playing 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 sanitizeFileName(name) {
    return name.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ').trim();
}

async function downloadTrack(trackId, trackInfo, button) {
    try {
        if (button) button.classList.add('loading');

        const spotifyId = trackId.split('/')[1];
        const apiUrl = `https://spotisongdownloader.vercel.app/${spotifyId}`;

        const response = await new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: apiUrl,
                responseType: 'json',
                onload: function(response) {
                    if (response.status >= 200 && response.status < 300) {
                        resolve(response);
                    } else {
                        reject(new Error('Failed to fetch track data'));
                    }
                },
                onerror: function() {
                    reject(new Error('Network error'));
                }
            });
        });

        const data = response.response;

        if (!data || !data.url) {
            throw new Error('Download URL not available');
        }

        const downloadUrl = data.url.startsWith('http://') 
            ? data.url.replace('http://', 'https://') 
            : (!data.url.startsWith('https://') ? `https://${data.url}` : data.url);

        if (trackInfo) {
            const fileName = sanitizeFileName(`${trackInfo.title} - ${trackInfo.artist}.m4a`);
            const link = document.createElement('a');
            link.href = downloadUrl;
            link.download = fileName;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
        } else {
            window.open(downloadUrl, '_blank');
        }
    } catch (error) {
        console.error('Download error:', error);
    } finally {
        if (button) {
            setTimeout(() => {
                button.classList.remove('loading');
            }, 1000);
        }
    }
}

function updateButtonStyle(button) {
    const { start, end } = BUTTON_GRADIENT;
    button.style.background = `linear-gradient(135deg, ${start}, ${end})`;
    button.title = `Download`;
}

function addButton(el, type) {
    const button = document.createElement('button');
    button.className = `btn ${type}`;

    const icon = document.createElement('div');
    icon.className = 'icon';

    const loadingIcon = document.createElement('div');
    loadingIcon.className = 'loading-icon';

    button.appendChild(icon);
    button.appendChild(loadingIcon);

    updateButtonStyle(button);

    el.appendChild(button);
    return button;
}

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

    if (type === 'artist') {
        const tracks = document.querySelectorAll('[role="gridcell"]');
        for (let i = 0; i < tracks.length; i++) {
            const track = tracks[i];
            if (track.querySelector('div[data-encore-id="text"].standalone-ellipsis-one-line') && !track.hasButtons) {
                const downloadButton = addButton(track, 'track');
                downloadButton.onclick = async function () {
                    const trackLink = track.querySelector('a[href^="/track"]');
                    if (trackLink) {
                        const spotifyId = trackLink.href.split('/').pop().split('?')[0];
                        const trackInfo = getTrackInfoFromArtist(track);
                        await downloadTrack(`track/${spotifyId}`, trackInfo, downloadButton);
                    }
                }
                track.hasButtons = true;
            }
        }
    } else {
        const tracks = document.querySelectorAll('[data-testid="tracklist-row"]');
        for (let i = 0; i < tracks.length; i++) {
            const track = tracks[i];
            if (!track.hasButtons) {
                const downloadButton = addButton(track, 'track');
                downloadButton.onclick = async function () {
                    const trackLink = track.querySelector('a[href^="/track"]');
                    if (trackLink) {
                        const spotifyId = trackLink.href.split('/').pop().split('?')[0];
                        const trackInfo = getTrackInfo(track);
                        await downloadTrack(`track/${spotifyId}`, trackInfo, downloadButton);
                    } else {
                        const btn = track.querySelector('[data-testid="more-button"]');
                        if (btn) {
                            btn.click();
                            await new Promise(resolve => setTimeout(resolve, 1));
                            const highlightEl = document.querySelector('#context-menu a[href*="highlight"]');
                            if (highlightEl) {
                                const highlight = highlightEl.href.match(/highlight=(.+)/)[1];
                                document.dispatchEvent(new MouseEvent('mousedown'));
                                const spotifyId = highlight.split(':')[2];
                                const trackInfo = getTrackInfo(track);
                                await downloadTrack(`track/${spotifyId}`, trackInfo, downloadButton);
                            }
                        }
                    }
                }
                track.hasButtons = true;
            }
        }
    }

    if (type === 'track') {
        const actionBarRow = document.querySelector('.eSg4ntPU2KQLfpLGXAww[data-testid="action-bar-row"]');
        if (actionBarRow && !actionBarRow.hasButtons) {
            const downloadButton = addButton(actionBarRow, 'track');
            downloadButton.onclick = async 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;
                await downloadTrack(`track/${id}`, trackInfo, downloadButton);
            }
            actionBarRow.hasButtons = true;
        }
    }
}

function addNowPlayingButton() {
    const downloadButton = document.createElement('button');
    downloadButton.className = 'Spotify-Downloader-Button';
    downloadButton.innerHTML = '<span aria-hidden="true" class="IconWrapper__Wrapper-sc-16usrgb-0 hYdsxw"><svg data-encore-id="icon" role="img" aria-hidden="true" viewBox="0 0 448 512" class="Svg-sc-ytk21e-0 dYnaPI" width="20" height="20" fill="currentColor"><path d="M374.6 214.6l-128 128c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 242.7 192 32c0-17.7 14.3-32 32-32s32 14.3 32 32l0 210.7 73.4-73.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM64 352l0 64c0 17.7 14.3 32 32 32l256 0c17.7 0 32-14.3 32-32l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 64c0 53-43 96-96 96L96 512c-53 0-96-43-96-96l0-64c0-17.7 14.3-32 32-32s32 14.3 32 32z"/></svg></span>';
    
    const loadingSpinner = document.createElement('div');
    loadingSpinner.className = 'spinner-icon';
    downloadButton.appendChild(loadingSpinner);

    downloadButton.style.cssText = `background:transparent;border:none;color:${PRIMARY_COLOR};cursor:pointer;padding:8px;margin:0 4px;transition:transform .2s ease;position:relative;`;
    
    downloadButton.onmouseover = () => downloadButton.style.transform = 'scale(1.1)';
    downloadButton.onmouseout = () => downloadButton.style.transform = 'scale(1)';
    
    downloadButton.onclick = async function() {
        const link = document.querySelector('a[href*="spotify:track:"]');
        if (link) {
            const match = link.getAttribute('href').match(/spotify:track:([a-zA-Z0-9]+)/);
            if (match) {
                downloadButton.classList.add('loading');
                const spotifyId = match[1];
                
                const trackInfo = getNowPlayingTrackInfo();
                
                await downloadTrack(`track/${spotifyId}`, trackInfo, downloadButton);
            }
        }
    };
    
    const container = document.querySelector('.snFK6_ei0caqvFI6As9Q')?.querySelector('.deomraqfhIAoSB3SgXpu');
    if (container && !container.querySelector('.Spotify-Downloader-Button')) {
        container.appendChild(downloadButton);
    }
}

const additionalCSS = `
.Spotify-Downloader-Button {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
}

.Spotify-Downloader-Button .spinner-icon {
    position: absolute;
    width: 20px;
    height: 20px;
    background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 256C0 114.9 114.1 .5 255.1 0C237.9 .5 224 14.6 224 32c0 17.7 14.3 32 32 32C150 64 64 150 64 256s86 192 192 192c69.7 0 130.7-37.1 164.5-92.6c-3 6.6-3.3 14.8-1 22.2c1.2 3.7 3 7.2 5.4 10.3c1.2 1.5 2.6 3 4.1 4.3c.8 .7 1.6 1.3 2.4 1.9c.4 .3 .8 .6 1.3 .9s.9 .6 1.3 .8c5 2.9 10.6 4.3 16 4.3c11 0 21.8-5.7 27.7-16c-44.3 76.5-127 128-221.7 128C114.6 512 0 397.4 0 256z" fill="%2300da5a"/><path class="fa-primary" d="M224 32c0-17.7 14.3-32 32-32C397.4 0 512 114.6 512 256c0 46.6-12.5 90.4-34.3 128c-8.8 15.3-28.4 20.5-43.7 11.7s-20.5-28.4-11.7-43.7c16.3-28.2 25.7-61 25.7-96c0-106-86-192-192-192c-17.7 0-32-14.3-32-32z" fill="%2300da5a"/></svg>');
    background-position: center;
    background-repeat: no-repeat;
    background-size: contain;
    opacity: 0;
    transition: opacity 0.2s ease;
}

.Spotify-Downloader-Button.loading .spinner-icon {
    opacity: 1;
    animation: spin 1s linear infinite;
}

.Spotify-Downloader-Button.loading span {
    opacity: 0;
}
`;

style.innerText = style.innerText + additionalCSS;

function animateLoop() {
    animate();
    addNowPlayingButton();
    requestAnimationFrame(animateLoop);
}

requestAnimationFrame(animateLoop);