// ==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);