Soundcloud Downloader Clean

An ad-less, multilingual, clean Soundcloud downloader with robust code. Adds a 'Download' button in the toolbar of all single track views.

  1. // ==UserScript==
  2. // @name Soundcloud Downloader Clean
  3. // @namespace https://openuserjs.org/users/webketje
  4. // @version 1.0.0
  5. // @description An ad-less, multilingual, clean Soundcloud downloader with robust code. Adds a 'Download' button in the toolbar of all single track views.
  6. // @author webketje
  7. // @license MIT
  8. // @icon https://a-v2.sndcdn.com/assets/images/sc-icons/favicon-2cadd14bdb.ico
  9. // @homepageURL https://gist.github.com/webketje/8cd2e6ae8a86dbe0533c5d2c612c42c6
  10. // @supportURL https://gist.github.com/webketje/8cd2e6ae8a86dbe0533c5d2c612c42c6#comments
  11. // @noframes
  12. // @match https://soundcloud.com/*
  13. // @grant unsafeWindow
  14. // @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js
  15. // ==/UserScript==
  16.  
  17. /* globals saveAs */
  18.  
  19. (function() {
  20. 'use strict';
  21.  
  22. var win = unsafeWindow || window;
  23. var containerSelector = '.soundActions.sc-button-toolbar .sc-button-group';
  24.  
  25. var scdl = {
  26. debug: false,
  27. client_id: '',
  28. dlButtonId: 'scdlc-btn',
  29. modalId: 'scdl-third-party-modal'
  30. };
  31.  
  32. var labels = ({
  33. en: {
  34. download: 'Download',
  35. downloading: 'Downloading',
  36. copy: 'Copy',
  37. copy_success: 'Copied to clipboard',
  38. copy_failure: 'Failed to copy to clipboard!',
  39. close: 'Close',
  40. modal_title: 'could not download this track. Use one of these third-party services instead?'
  41. },
  42. es: {
  43. download: 'Descargar',
  44. downloading: 'Descargando..',
  45. copy: 'Copiar',
  46. copy_success: 'Copiada al portapapeles',
  47. copy_failure: '¡No se pudo copiar al portapapeles!',
  48. close: '',
  49. modal_title: 'no se pudo descargar esta banda sonora. ¿Utilizar uno de estos servicios de terceros en su lugar?'
  50. },
  51. fr: {
  52. download: 'Télécharger',
  53. downloading: 'Téléchargement..',
  54. copy: 'Copier',
  55. copy_success: 'Copié dans le presse-papiers!',
  56. copy_failure: 'Échec de la copie dans le presse-papiers !',
  57. close: 'Fermer',
  58. modal_title: 'ne peut pas télécharger ce fichier. Utiliser l’un de ces services tiers ?'
  59. },
  60. nl: {
  61. download: 'Downloaden',
  62. downloading: 'Downloaden..',
  63. copy: 'Kopiëren',
  64. copy_success: 'Naar klembord gekopieerd!',
  65. copy_failure: 'Kopiëren naar klembord mislukt!',
  66. close: 'Sluiten',
  67. modal_title: 'kon dit bestand niet downloaden. Een van deze externe diensten gebruiken?'
  68. },
  69. de: {
  70. download: 'Herunterladen',
  71. downloading: 'Herunterladen..',
  72. copy: 'Kopieren',
  73. copy_success: 'In die Zwischenablage kopiert',
  74. copy_failure: 'Kopieren in die Zwischenablage fehlgeschlagen!',
  75. close: 'Schließen',
  76. modal_title: 'konnte diesen Sound nicht herunterladen. Nutzen Sie stattdessen einen dieser Drittanbieterdienste?'
  77. },
  78. pl: {
  79. download: 'Ściągnij',
  80. downloading: 'Ściąganie..',
  81. copy: 'Kopiuj',
  82. copy_success: 'Skopiowano do schowka',
  83. copy_failure: 'Nie udało się skopiować do schowka!!',
  84. close: 'Zamknij',
  85. modal_title: 'nie udało się pobrać tego utworu. Zamiast tego skorzystać z jednej z usług stron trzecich?'
  86. },
  87. it: {
  88. download: 'Scaricare',
  89. downloading: 'Scaricando..',
  90. copy: 'Copia',
  91. copy_success: 'Copiato negli appunti',
  92. copy_failure: 'Impossibile copiare negli appunti!',
  93. close: 'Chiudi',
  94. modal_title: 'non è stato possibile scaricare questo suono. Utilizzi invece uno di questi servizi di terze parti?'
  95. },
  96. pt_BR: {
  97. download: 'Baixar',
  98. downloading: 'Baixando..',
  99. copy: 'Copiar',
  100. copy_success: 'Copiado para a área de transferência',
  101. copy_failure: 'Falha ao copiar para a área de transferência!!',
  102. close: 'Fechar',
  103. modal_title: 'não foi possível baixar este som. Usar um desses serviços de terceiros?'
  104. },
  105. sv: {
  106. download: 'Ladda ner',
  107. downloading: 'Laddar ner..',
  108. copy: 'Kopiera',
  109. copy_success: 'Kopierat till urklipp',
  110. copy_failure: 'Det gick inte att kopiera till urklipp!',
  111. close: 'Stäng',
  112. modal_title: 'han kunde inte ladda ner det här ljudet. Använd någon av dessa tredjepartstjänster istället?'
  113. }
  114. })[document.documentElement.lang || 'en']
  115.  
  116. /**
  117. * @desc Log to console only if debug is true
  118. */
  119. function log() {
  120. var stamp = new Date().toLocaleString(),
  121. args = [].slice.call(arguments),
  122. prefix = ['SCDLC', stamp, '-'].join(' ');
  123. if (scdl.debug) console.log.apply(console, [prefix + args[0]].concat(args.slice(1)));
  124. };
  125.  
  126. /**
  127. * @desc There is no other way to retrieve a Soundcloud client_id than by spying on existing requests.
  128. * We temporarily patch the XHR.send method to retrieve the url passed to it.
  129. * @param restoreIfTrue - restores the original prototype method when true is returned
  130. * @param onRestore - a function to exec when the restoreIfTrue condition is met
  131. */
  132. function patchXHR(restoreIfTrue, onRestore) {
  133. var originalXHR = win.XMLHttpRequest.prototype.open;
  134.  
  135. win.XMLHttpRequest.prototype.open = function() {
  136. originalXHR.apply(this, arguments);
  137. var restore = restoreIfTrue.apply(this, arguments);
  138. if (restore) {
  139. win.XMLHttpRequest.prototype.open = originalXHR;
  140. onRestore(restore);
  141. }
  142. };
  143. };
  144.  
  145. scdl.getTrackName = function(trackJSON) {
  146. return [
  147. trackJSON.user.username,
  148. trackJSON.title
  149. ].join(' - ');
  150. };
  151.  
  152. scdl.getMediaURL = function(json, onresolve, onerror) {
  153. if (json.media && json.media.transcodings) {
  154. var found = json.media.transcodings.filter(function(tc) {
  155. return tc.format && tc.format.protocol === 'progressive';
  156. })[0];
  157. if (found) {
  158. var xhr = new XMLHttpRequest();
  159. xhr.onload = function() {
  160. var result;
  161. try {
  162. result = JSON.parse(xhr.responseText);
  163. } catch (err) {}
  164. if (result && result.url)
  165. onresolve(result.url);
  166. else
  167. onerror(false);
  168. };
  169. xhr.onerror = onerror;
  170. xhr.open('GET', found.url + '?client_id=' + scdl.client_id);
  171. xhr.send();
  172. } else {
  173. onerror(false);
  174. }
  175. } else {
  176. onerror(false);
  177. }
  178. };
  179.  
  180. scdl.getStreamURL = function(url, onresolve, onerror) {
  181. var xhr = new XMLHttpRequest();
  182. xhr.onload = function() {
  183. var trackJSON = JSON.parse(xhr.responseText);
  184. scdl.getMediaURL(trackJSON, function resolve(url) {
  185. onresolve({
  186. stream_url: url,
  187. track_name: scdl.getTrackName(trackJSON)
  188. });
  189. }, function reject() {
  190. onerror(false);
  191. })
  192. }.bind(this);
  193. xhr.onerror = function() {
  194. onerror(false);
  195. };
  196. xhr.open('GET', 'https://api-v2.soundcloud.com/resolve?url=' + encodeURIComponent(url) + '&client_id=' + this.client_id);
  197. xhr.send();
  198. };
  199.  
  200. scdl.button = {
  201. download: function(e) {
  202. e.preventDefault();
  203. var dlButton = document.getElementById(scdl.dlButtonId)
  204. if (dlButton) {
  205. dlButton.textContent = labels.downloading;
  206. }
  207. setTimeout(function() {
  208. saveAs(e.target.href, e.target.dataset.title);
  209. if (dlButton) {
  210. dlButton.textContent = labels.download;
  211. }
  212. }, 100)
  213. },
  214. render: function(href, title, onClick) {
  215. var label = labels.download;
  216. var a = document.createElement('a');
  217. a.className = "sc-button sc-button-medium sc-button-responsive sc-button-download";
  218. a.href = href;
  219. a.id = scdl.dlButtonId;
  220. a.textContent = label;
  221. a.title = label;
  222. a.dataset.title = title + '.mp3';
  223. a.setAttribute('download', title + '.mp3');
  224. a.target = '_blank';
  225. a.onclick = onClick;
  226. a.style.marginLeft = '5px';
  227. a.style.cssFloat = 'left';
  228. a.style.border = '1px solid orangered';
  229. return a;
  230. },
  231. attach:function() {
  232. var args = arguments, self = this, iterations = 0
  233.  
  234. // account for rendering delays
  235. var intv = setInterval(function() {
  236. var f = document.querySelector(containerSelector)
  237. iterations++
  238. if (f && !document.getElementById(scdl.dlButtonId)) {
  239. f.insertAdjacentElement('beforeend', self.render.apply(self, args));
  240. log('Attaching download button to element:', f)
  241. clearInterval(intv)
  242. // stop after trying to find the element for 5s
  243. } else if (iterations === 50) {
  244. log('%c Couldn\'t find element "' + containerSelector + '" after 2 seconds', 'color: #FF0000;')
  245. clearInterval(intv)
  246. }
  247. }, 100)
  248. },
  249. remove: function() {
  250. var btn = document.getElementById(scdl.dlButtonId);
  251. if (btn)
  252. btn.parentNode.removeChild(btn);
  253. }
  254. };
  255.  
  256. scdl.modal = {
  257. providers: [
  258. 'aHR0cHM6Ly9zY2xvdWRkb3dubG9hZGVyLm5ldA==',
  259. 'aHR0cHM6Ly93d3cuc291bmRjbG91ZG1wMy5vcmc=',
  260. 'aHR0cHM6Ly9zb3VuZGNsb3VkbWUuY29t'
  261. ],
  262. render: function(title) {
  263. var temp = document.createElement('div'), self = this
  264. const html = [
  265. '<div class="modal g-z-index-modal-background g-opacity-transition g-z-index-overlay modalWhiteout showBackground g-backdrop-filter-grayscale" style="outline: none; padding-right: 0px; display: flex; justify-content: center;" tabindex="-1" id="scdl-third-party-modal">',
  266. '<div class="modal__modal sc-border-box g-z-index-modal-content transparentBackground" style="height: auto;">',
  267. '<button type="button" title="' + labels.close + '" class="modal__closeButton">' + labels.close + '</button>',
  268. '<div class="modal__content"><div class="tabs"><div class="tabs__content"><div class="tabs__contentSlot" style="display: block;"><article class="shareContent">',
  269. '<div class="publicShare"><section class="g-modal-section sc-clearfix sc-pt-2x">',
  270. '<h2 class="sc-orange">Soundcloud Downloader Clean ' + labels.modal_title + '</h2>',
  271. '</section><section class="g-modal-section sc-clearfix sc-pt-2x">',
  272. '<h3 style="margin-bottom: 0.5rem;">' + labels.download + ' <em>' + title + '</em> via: </h3>',
  273. this.providers.map(p => ['<div><a href="', win.atob(p), '" target="_blank" style="display: inline-block; font-size: 14px; padding: 0.25rem 0;">', win.atob(p), '</a></div>'].join('')).join(''),
  274. '<div class="shareLink sc-clearfix publicShare__link sc-pt-2x m-showPositionOption" style="margin-top: 1rem;">',
  275. '<label for="shareLink__field" style="margin-right:0.5rem;">Link</label>',
  276. '<input type="text" value="' + win.location.href + '" class="shareLink__field sc-input" id="shareLink__field" readonly="readonly">',
  277. '<button class="sc-button sc-button-copy">' + labels.copy + '</button>',
  278. '<span class="sc-copy-feedback" style="margin-left: 1rem;"></span>',
  279. '</div>',
  280. '</section></div></article></div></div></div></div></div></div>'
  281. ].join('')
  282. temp.innerHTML = html
  283. var cnt = temp.firstElementChild
  284. cnt.addEventListener('click', function(e) {
  285. if (this === e.target || e.target.classList.contains('modal__closeButton')) {
  286. self.remove()
  287. } else if (e.target.classList.contains('sc-button-copy')) {
  288. navigator.clipboard.writeText(win.location.href)
  289. .then(function() {
  290. var f = cnt.querySelector('.sc-copy-feedback')
  291. f.innerHTML = '<span style="color: green;">Copied to clipboard!</span>'
  292. }, function(err) {
  293. log('Failed to write URL to the clipboard.', err)
  294. var f = cnt.querySelector('.sc-copy-feedback')
  295. f.innerHTML = '<span style="color: red;">Failed to copy to clipboard!</span>'
  296. })
  297. }
  298. })
  299. return cnt
  300. },
  301. attach: function() {
  302. this.remove()
  303. document.body.appendChild(this.render.apply(this, arguments))
  304. },
  305. remove: function() {
  306. var modal = document.getElementById(scdl.modalId);
  307. if (modal)
  308. modal.parentNode.removeChild(modal);
  309. }
  310. }
  311.  
  312. scdl.parseClientIdFromURL = function(url) {
  313. var search = /client_id=([\w\d]+)&*/;
  314. return url && url.match(search) && url.match(search)[1];
  315. };
  316.  
  317. scdl.getClientID = function(onClientIDFound) {
  318. patchXHR(function(method, url) {
  319. return scdl.parseClientIdFromURL(url);
  320. }, onClientIDFound);
  321. };
  322.  
  323. scdl.load = function(url) {
  324. // for now only make available for single track pages
  325. if (/^(\/(you|stations|discover|stream|upload|search|settings|.+?\/sets))/.test(win.location.pathname)) {
  326. scdl.button.remove();
  327. return;
  328. }
  329.  
  330. scdl.getStreamURL(url,
  331. function onSuccess(result) {
  332. if (!result) {
  333. scdl.button.remove();
  334. } else {
  335. log('Detected valid Soundcloud artist track URL. Requesting info...');
  336. scdl.button.attach(
  337. result.stream_url,
  338. result.track_name,
  339. scdl.button.download
  340. );
  341. }
  342. },
  343. function onError() {
  344. log('%c No compatible media transcoding found.', 'color: #FF0000;');
  345. scdl.button.attach('javascript:void(0);', 'None', function() {
  346. var title = document.querySelector('.soundTitle__title')
  347. var artist = document.querySelector('.soundTitle__username')
  348. scdl.modal.attach([artist.textContent.trim(), '-', title.textContent.trim()].join(' '))
  349. })
  350. }
  351. );
  352. };
  353.  
  354. // patch front-end navigation
  355. ['pushState','replaceState','forward','back','go'].forEach(function(event) {
  356. var tmp = win.history.pushState;
  357. win.history[event] = function() {
  358. tmp.apply(win.history, arguments);
  359. scdl.load(win.location.href);
  360. }
  361. });
  362. if (scdl.debug) win.scdl = scdl;
  363. scdl.getClientID(function(id) {
  364. log('Found Soundcloud client id:', id, '. Initializing...');
  365. scdl.client_id = id;
  366. scdl.load(win.location.href);
  367. });
  368. })();