- // ==UserScript==
- // @name Spotify Direct 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 1.0
- // @author afkarxyz
- // @namespace https://github.com/afkarxyz/userscripts/
- // @supportURL https://github.com/afkarxyz/userscripts/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 spotifyUrl = `https://open.spotify.com/track/${spotifyId}`;
- const apiUrl = 'https://parsevideoapi.videosolo.com/spotify-api/';
-
- const response = await new Promise((resolve, reject) => {
- GM_xmlhttpRequest({
- method: 'POST',
- url: apiUrl,
- headers: {
- 'Content-Type': 'application/json'
- },
- data: JSON.stringify({
- "format": "web",
- "url": spotifyUrl
- }),
- 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 || data.status !== "200" || !data.data || !data.data.metadata) {
- throw new Error('Invalid API response: No valid data returned');
- }
-
- const metadata = data.data.metadata;
- if (!metadata.download) {
- throw new Error('Download URL not available');
- }
-
- const downloadUrl = metadata.download;
-
- if (trackInfo) {
- const fileName = sanitizeFileName(`${trackInfo.title} - ${trackInfo.artist}.mp3`);
-
- 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);
- 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);