You need to sign in or sign up before continuing.
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.5 // @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 // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/browser-id3-writer.min.js // ==/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 fetchAndEmbedMetadata(url, metadata) { try { const response = await fetch(url); if (!response.ok) { throw new Error('Failed to fetch audio file'); } const arrayBuffer = await response.arrayBuffer(); const writer = new ID3Writer(arrayBuffer); writer.setFrame('TIT2', metadata.song_name || metadata.title) .setFrame('TPE1', [metadata.artist]) .setFrame('TALB', metadata.album_name || 'Unknown Album'); if (metadata.released) { writer.setFrame('TYER', metadata.released.split('-')[0]); } if (metadata.img) { try { const imgResponse = await fetch(metadata.img); if (imgResponse.ok) { const imgArrayBuffer = await imgResponse.arrayBuffer(); const imgUint8Array = new Uint8Array(imgArrayBuffer); let mimeType = 'image/jpeg'; const header = Array.from(imgUint8Array.slice(0, 4).values()) .map(byte => byte.toString(16).padStart(2, '0')) .join(''); if (header.startsWith('89504e47')) { mimeType = 'image/png'; } else if (header.startsWith('47494638')) { mimeType = 'image/gif'; } writer.setFrame('APIC', { type: 3, data: imgUint8Array, description: 'Cover', mimeType: mimeType }); } } catch (imgError) { console.error('Error fetching cover image:', imgError); } } writer.addTag(); const taggedBlob = new Blob([writer.arrayBuffer], { type: 'audio/mpeg' }); return URL.createObjectURL(taggedBlob); } catch (error) { console.error('Error embedding metadata:', error); return url; } } 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: ${response.status}`)); } }, onerror: function(error) { reject(new Error(`Network error: ${error}`)); } }); }); const data = response.response; if (!data) { throw new Error('Invalid API response: No data returned'); } if (!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 fileExtension = (data.source === 'fallback') ? '.mp3' : '.m4a'; let fileName = sanitizeFileName(`${trackInfo.title} - ${trackInfo.artist}${fileExtension}`); let finalUrl = downloadUrl; if (data.source === 'fallback' && data.metadata) { if (button) button.title = 'Processing metadata...'; finalUrl = await fetchAndEmbedMetadata(downloadUrl, data.metadata); } const link = document.createElement('a'); link.href = finalUrl; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); if (finalUrl !== downloadUrl) { URL.revokeObjectURL(finalUrl); } } else { window.open(downloadUrl, '_blank'); } } catch (error) { console.error('Download error:', error); alert(`Download failed: ${error.message}`); } finally { if (button) { setTimeout(() => { button.classList.remove('loading'); button.title = 'Download'; }, 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);