Greasy Fork is available in English.

Spotify Direct Downloader

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

  1. // ==UserScript==
  2. // @name Spotify Direct Downloader
  3. // @description Adds convenient download buttons to Spotify tracks, allowing users to download music directly from the web.
  4. // @icon https://www.google.com/s2/favicons?sz=64&domain=spotify.com
  5. // @version 1.0
  6. // @author afkarxyz
  7. // @namespace https://github.com/afkarxyz/userscripts/
  8. // @supportURL https://github.com/afkarxyz/userscripts/issues
  9. // @license MIT
  10. // @match *://open.spotify.com/*
  11. // @grant GM_xmlhttpRequest
  12. // ==/UserScript==
  13.  
  14. const PRIMARY_COLOR = '#00da5a';
  15. const DEFAULT_COLOR = '#ffffff';
  16.  
  17. const BUTTON_GRADIENT = { start: PRIMARY_COLOR, end: '#008035' };
  18.  
  19. const style = document.createElement('style');
  20. style.innerText = `
  21. [role='grid'] {
  22. margin-left: 50px;
  23. }
  24. [data-testid="tracklist-row"] {
  25. position: relative;
  26. }
  27. [role="presentation"] > * {
  28. contain: unset;
  29. }
  30. .btn {
  31. width: 40px;
  32. height: 40px;
  33. border-radius: 50%;
  34. border: 0;
  35. position: relative;
  36. cursor: pointer;
  37. transition: all 0.2s ease;
  38. box-shadow: 0 2px 5px rgba(0,0,0,0.2);
  39. display: flex;
  40. align-items: center;
  41. justify-content: center;
  42. }
  43. .btn::after {
  44. content: '';
  45. position: absolute;
  46. top: 50%;
  47. left: 50%;
  48. transform: translate(-50%, -50%);
  49. width: 50%;
  50. height: 50%;
  51. background-position: center;
  52. background-repeat: no-repeat;
  53. background-size: contain;
  54. transition: opacity 0.2s ease;
  55. }
  56. .btn .icon {
  57. position: absolute;
  58. width: 50%;
  59. height: 50%;
  60. background-position: center;
  61. background-repeat: no-repeat;
  62. background-size: contain;
  63. transition: opacity 0.2s ease;
  64. opacity: 1;
  65. }
  66. .btn .loading-icon {
  67. position: absolute;
  68. width: 50%;
  69. height: 50%;
  70. background-position: center;
  71. background-repeat: no-repeat;
  72. background-size: contain;
  73. transition: opacity 0.2s ease;
  74. opacity: 0;
  75. 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>');
  76. }
  77. .btn.loading .loading-icon {
  78. opacity: 1;
  79. animation: spin 1s linear infinite;
  80. }
  81. .btn.loading .icon {
  82. opacity: 0;
  83. }
  84. @keyframes spin {
  85. from { transform: rotate(0deg); }
  86. to { transform: rotate(360deg); }
  87. }
  88. .N7GZp8IuWPJvCPz_7dOg .btn {
  89. width: 24px;
  90. height: 24px;
  91. margin-top: -12px !important;
  92. }
  93. .N7GZp8IuWPJvCPz_7dOg .btn::after {
  94. transform: translate(-50%, -50%) scale(0.85);
  95. width: 65%;
  96. height: 65%;
  97. }
  98. .N7GZp8IuWPJvCPz_7dOg .btn .icon,
  99. .N7GZp8IuWPJvCPz_7dOg .btn .loading-icon {
  100. transform: scale(0.85);
  101. }
  102. .btn.track .icon {
  103. 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>');
  104. }
  105. .btn:hover {
  106. transform: scale(1.1);
  107. box-shadow: 0 4px 8px rgba(0,0,0,0.3);
  108. }
  109. [data-testid="tracklist-row"] .btn {
  110. position: absolute;
  111. top: 50%;
  112. right: 100%;
  113. margin-top: -20px;
  114. margin-right: 10px;
  115. }
  116. `;
  117. document.body.appendChild(style);
  118.  
  119. function getTrackInfo(trackElement) {
  120. const titleElement = trackElement.querySelector('div[data-encore-id="text"].standalone-ellipsis-one-line');
  121. const artistElements = trackElement.querySelectorAll('span.encore-text-body-small[data-encore-id="text"] a[href^="/artist"]');
  122.  
  123. if (titleElement && artistElements.length > 0) {
  124. const artists = Array.from(artistElements)
  125. .map(el => el.textContent.trim())
  126. .join(', ');
  127.  
  128. return {
  129. title: titleElement.textContent.trim(),
  130. artist: artists
  131. };
  132. }
  133. return null;
  134. }
  135.  
  136. function getTrackInfoFromArtist(trackElement) {
  137. const titleElement = trackElement.querySelector('div[data-encore-id="text"].standalone-ellipsis-one-line');
  138. const artistElement = document.querySelector('span[data-testid="entityTitle"] h1');
  139.  
  140. if (titleElement && artistElement) {
  141. return {
  142. title: titleElement.textContent.trim(),
  143. artist: artistElement.textContent.trim()
  144. };
  145. }
  146. return null;
  147. }
  148.  
  149. function getNowPlayingTrackInfo() {
  150. const titleElement = document.querySelector('.FpKgwQJLYNDWugII3H4h, [data-testid="now-playing-widget"] .encore-text-body-small[data-encore-id="text"], .now-playing a[href^="/track"]');
  151. const artistElements = document.querySelectorAll('.jcGcOP.ggUwFI, [data-testid="now-playing-widget"] a[href^="/artist"], .now-playing a[href^="/artist"]');
  152. if (titleElement && artistElements.length > 0) {
  153. const artists = Array.from(artistElements)
  154. .map(el => el.textContent.trim())
  155. .join(', ');
  156. return {
  157. title: titleElement.textContent.trim(),
  158. artist: artists
  159. };
  160. }
  161. return null;
  162. }
  163.  
  164. function sanitizeFileName(name) {
  165. return name.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ').trim();
  166. }
  167.  
  168. async function downloadTrack(trackId, trackInfo, button) {
  169. try {
  170. if (button) button.classList.add('loading');
  171.  
  172. const spotifyId = trackId.split('/')[1];
  173. const spotifyUrl = `https://open.spotify.com/track/${spotifyId}`;
  174. const apiUrl = 'https://parsevideoapi.videosolo.com/spotify-api/';
  175.  
  176. const response = await new Promise((resolve, reject) => {
  177. GM_xmlhttpRequest({
  178. method: 'POST',
  179. url: apiUrl,
  180. headers: {
  181. 'Content-Type': 'application/json'
  182. },
  183. data: JSON.stringify({
  184. "format": "web",
  185. "url": spotifyUrl
  186. }),
  187. responseType: 'json',
  188. onload: function(response) {
  189. if (response.status >= 200 && response.status < 300) {
  190. resolve(response);
  191. } else {
  192. reject(new Error(`Failed to fetch track data: ${response.status}`));
  193. }
  194. },
  195. onerror: function(error) {
  196. reject(new Error(`Network error: ${error}`));
  197. }
  198. });
  199. });
  200.  
  201. const data = response.response;
  202.  
  203. if (!data || data.status !== "200" || !data.data || !data.data.metadata) {
  204. throw new Error('Invalid API response: No valid data returned');
  205. }
  206. const metadata = data.data.metadata;
  207. if (!metadata.download) {
  208. throw new Error('Download URL not available');
  209. }
  210.  
  211. const downloadUrl = metadata.download;
  212.  
  213. if (trackInfo) {
  214. const fileName = sanitizeFileName(`${trackInfo.title} - ${trackInfo.artist}.mp3`);
  215. const link = document.createElement('a');
  216. link.href = downloadUrl;
  217. link.download = fileName;
  218. document.body.appendChild(link);
  219. link.click();
  220. document.body.removeChild(link);
  221. } else {
  222. window.open(downloadUrl, '_blank');
  223. }
  224. } catch (error) {
  225. console.error('Download error:', error);
  226. alert(`Download failed: ${error.message}`);
  227. } finally {
  228. if (button) {
  229. setTimeout(() => {
  230. button.classList.remove('loading');
  231. button.title = 'Download';
  232. }, 1000);
  233. }
  234. }
  235. }
  236.  
  237. function updateButtonStyle(button) {
  238. const { start, end } = BUTTON_GRADIENT;
  239. button.style.background = `linear-gradient(135deg, ${start}, ${end})`;
  240. button.title = `Download`;
  241. }
  242.  
  243. function addButton(el, type) {
  244. const button = document.createElement('button');
  245. button.className = `btn ${type}`;
  246.  
  247. const icon = document.createElement('div');
  248. icon.className = 'icon';
  249.  
  250. const loadingIcon = document.createElement('div');
  251. loadingIcon.className = 'loading-icon';
  252.  
  253. button.appendChild(icon);
  254. button.appendChild(loadingIcon);
  255.  
  256. updateButtonStyle(button);
  257.  
  258. el.appendChild(button);
  259. return button;
  260. }
  261.  
  262. function animate() {
  263. const currentUrl = window.location.href;
  264. const urlParts = currentUrl.split('/');
  265. const type = urlParts[3];
  266.  
  267. if (type === 'artist') {
  268. const tracks = document.querySelectorAll('[role="gridcell"]');
  269. for (let i = 0; i < tracks.length; i++) {
  270. const track = tracks[i];
  271. if (track.querySelector('div[data-encore-id="text"].standalone-ellipsis-one-line') && !track.hasButtons) {
  272. const downloadButton = addButton(track, 'track');
  273. downloadButton.onclick = async function () {
  274. const trackLink = track.querySelector('a[href^="/track"]');
  275. if (trackLink) {
  276. const spotifyId = trackLink.href.split('/').pop().split('?')[0];
  277. const trackInfo = getTrackInfoFromArtist(track);
  278. await downloadTrack(`track/${spotifyId}`, trackInfo, downloadButton);
  279. }
  280. }
  281. track.hasButtons = true;
  282. }
  283. }
  284. } else {
  285. const tracks = document.querySelectorAll('[data-testid="tracklist-row"]');
  286. for (let i = 0; i < tracks.length; i++) {
  287. const track = tracks[i];
  288. if (!track.hasButtons) {
  289. const downloadButton = addButton(track, 'track');
  290. downloadButton.onclick = async function () {
  291. const trackLink = track.querySelector('a[href^="/track"]');
  292. if (trackLink) {
  293. const spotifyId = trackLink.href.split('/').pop().split('?')[0];
  294. const trackInfo = getTrackInfo(track);
  295. await downloadTrack(`track/${spotifyId}`, trackInfo, downloadButton);
  296. } else {
  297. const btn = track.querySelector('[data-testid="more-button"]');
  298. if (btn) {
  299. btn.click();
  300. await new Promise(resolve => setTimeout(resolve, 1));
  301. const highlightEl = document.querySelector('#context-menu a[href*="highlight"]');
  302. if (highlightEl) {
  303. const highlight = highlightEl.href.match(/highlight=(.+)/)[1];
  304. document.dispatchEvent(new MouseEvent('mousedown'));
  305. const spotifyId = highlight.split(':')[2];
  306. const trackInfo = getTrackInfo(track);
  307. await downloadTrack(`track/${spotifyId}`, trackInfo, downloadButton);
  308. }
  309. }
  310. }
  311. }
  312. track.hasButtons = true;
  313. }
  314. }
  315. }
  316.  
  317. if (type === 'track') {
  318. const actionBarRow = document.querySelector('.eSg4ntPU2KQLfpLGXAww[data-testid="action-bar-row"]');
  319. if (actionBarRow && !actionBarRow.hasButtons) {
  320. const downloadButton = addButton(actionBarRow, 'track');
  321. downloadButton.onclick = async function () {
  322. const id = urlParts[4].split('?')[0];
  323. const titleElement = document.querySelector('h1');
  324. const artistElement = document.querySelector('a[href^="/artist"]');
  325. const trackInfo = titleElement && artistElement ? {
  326. title: titleElement.textContent.trim(),
  327. artist: artistElement.textContent.trim()
  328. } : null;
  329. await downloadTrack(`track/${id}`, trackInfo, downloadButton);
  330. }
  331. actionBarRow.hasButtons = true;
  332. }
  333. }
  334. }
  335.  
  336. function addNowPlayingButton() {
  337. const downloadButton = document.createElement('button');
  338. downloadButton.className = 'Spotify-Downloader-Button';
  339. 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>';
  340. const loadingSpinner = document.createElement('div');
  341. loadingSpinner.className = 'spinner-icon';
  342. downloadButton.appendChild(loadingSpinner);
  343.  
  344. downloadButton.style.cssText = `background:transparent;border:none;color:${PRIMARY_COLOR};cursor:pointer;padding:8px;margin:0 4px;transition:transform .2s ease;position:relative;`;
  345. downloadButton.onmouseover = () => downloadButton.style.transform = 'scale(1.1)';
  346. downloadButton.onmouseout = () => downloadButton.style.transform = 'scale(1)';
  347. downloadButton.onclick = async function() {
  348. const link = document.querySelector('a[href*="spotify:track:"]');
  349. if (link) {
  350. const match = link.getAttribute('href').match(/spotify:track:([a-zA-Z0-9]+)/);
  351. if (match) {
  352. downloadButton.classList.add('loading');
  353. const spotifyId = match[1];
  354. const trackInfo = getNowPlayingTrackInfo();
  355. await downloadTrack(`track/${spotifyId}`, trackInfo, downloadButton);
  356. }
  357. }
  358. };
  359. const container = document.querySelector('.snFK6_ei0caqvFI6As9Q')?.querySelector('.deomraqfhIAoSB3SgXpu');
  360. if (container && !container.querySelector('.Spotify-Downloader-Button')) {
  361. container.appendChild(downloadButton);
  362. }
  363. }
  364.  
  365. const additionalCSS = `
  366. .Spotify-Downloader-Button {
  367. position: relative;
  368. display: flex;
  369. align-items: center;
  370. justify-content: center;
  371. }
  372.  
  373. .Spotify-Downloader-Button .spinner-icon {
  374. position: absolute;
  375. width: 20px;
  376. height: 20px;
  377. 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>');
  378. background-position: center;
  379. background-repeat: no-repeat;
  380. background-size: contain;
  381. opacity: 0;
  382. transition: opacity 0.2s ease;
  383. }
  384.  
  385. .Spotify-Downloader-Button.loading .spinner-icon {
  386. opacity: 1;
  387. animation: spin 1s linear infinite;
  388. }
  389.  
  390. .Spotify-Downloader-Button.loading span {
  391. opacity: 0;
  392. }
  393. `;
  394.  
  395. style.innerText = style.innerText + additionalCSS;
  396.  
  397. function animateLoop() {
  398. animate();
  399. addNowPlayingButton();
  400. requestAnimationFrame(animateLoop);
  401. }
  402.  
  403. requestAnimationFrame(animateLoop);