NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Soundcloud Downloader Clean // @namespace https://openuserjs.org/users/webketje // @version 1.0.0 // @description An ad-less, multilingual, clean Soundcloud downloader with robust code. Adds a 'Download' button in the toolbar of all single track views. // @author webketje // @license MIT // @icon https://a-v2.sndcdn.com/assets/images/sc-icons/favicon-2cadd14bdb.ico // @homepageURL https://gist.github.com/webketje/8cd2e6ae8a86dbe0533c5d2c612c42c6 // @supportURL https://gist.github.com/webketje/8cd2e6ae8a86dbe0533c5d2c612c42c6#comments // @updateURL https://openuserjs.org/meta/webketje/Soundcloud_Downloader_Clean.meta.js // @downloadURL https://openuserjs.org/install/webketje/Soundcloud_Downloader_Clean.user.js // @noframes // @match https://soundcloud.com/* // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js // ==/UserScript== /* globals saveAs */ (function() { 'use strict'; var win = unsafeWindow || window; var containerSelector = '.soundActions.sc-button-toolbar .sc-button-group'; var scdl = { debug: false, client_id: '', dlButtonId: 'scdlc-btn', modalId: 'scdl-third-party-modal' }; var labels = ({ en: { download: 'Download', downloading: 'Downloading', copy: 'Copy', copy_success: 'Copied to clipboard', copy_failure: 'Failed to copy to clipboard!', close: 'Close', modal_title: 'could not download this track. Use one of these third-party services instead?' }, es: { download: 'Descargar', downloading: 'Descargando..', copy: 'Copiar', copy_success: 'Copiada al portapapeles', copy_failure: '¡No se pudo copiar al portapapeles!', close: '', modal_title: 'no se pudo descargar esta banda sonora. ¿Utilizar uno de estos servicios de terceros en su lugar?' }, fr: { download: 'Télécharger', downloading: 'Téléchargement..', copy: 'Copier', copy_success: 'Copié dans le presse-papiers!', copy_failure: 'Échec de la copie dans le presse-papiers !', close: 'Fermer', modal_title: 'ne peut pas télécharger ce fichier. Utiliser l’un de ces services tiers ?' }, nl: { download: 'Downloaden', downloading: 'Downloaden..', copy: 'Kopiëren', copy_success: 'Naar klembord gekopieerd!', copy_failure: 'Kopiëren naar klembord mislukt!', close: 'Sluiten', modal_title: 'kon dit bestand niet downloaden. Een van deze externe diensten gebruiken?' }, de: { download: 'Herunterladen', downloading: 'Herunterladen..', copy: 'Kopieren', copy_success: 'In die Zwischenablage kopiert', copy_failure: 'Kopieren in die Zwischenablage fehlgeschlagen!', close: 'Schließen', modal_title: 'konnte diesen Sound nicht herunterladen. Nutzen Sie stattdessen einen dieser Drittanbieterdienste?' }, pl: { download: 'Ściągnij', downloading: 'Ściąganie..', copy: 'Kopiuj', copy_success: 'Skopiowano do schowka', copy_failure: 'Nie udało się skopiować do schowka!!', close: 'Zamknij', modal_title: 'nie udało się pobrać tego utworu. Zamiast tego skorzystać z jednej z usług stron trzecich?' }, it: { download: 'Scaricare', downloading: 'Scaricando..', copy: 'Copia', copy_success: 'Copiato negli appunti', copy_failure: 'Impossibile copiare negli appunti!', close: 'Chiudi', modal_title: 'non è stato possibile scaricare questo suono. Utilizzi invece uno di questi servizi di terze parti?' }, pt_BR: { download: 'Baixar', downloading: 'Baixando..', copy: 'Copiar', copy_success: 'Copiado para a área de transferência', copy_failure: 'Falha ao copiar para a área de transferência!!', close: 'Fechar', modal_title: 'não foi possível baixar este som. Usar um desses serviços de terceiros?' }, sv: { download: 'Ladda ner', downloading: 'Laddar ner..', copy: 'Kopiera', copy_success: 'Kopierat till urklipp', copy_failure: 'Det gick inte att kopiera till urklipp!', close: 'Stäng', modal_title: 'han kunde inte ladda ner det här ljudet. Använd någon av dessa tredjepartstjänster istället?' } })[document.documentElement.lang || 'en'] /** * @desc Log to console only if debug is true */ function log() { var stamp = new Date().toLocaleString(), args = [].slice.call(arguments), prefix = ['SCDLC', stamp, '-'].join(' '); if (scdl.debug) console.log.apply(console, [prefix + args[0]].concat(args.slice(1))); }; /** * @desc There is no other way to retrieve a Soundcloud client_id than by spying on existing requests. * We temporarily patch the XHR.send method to retrieve the url passed to it. * @param restoreIfTrue - restores the original prototype method when true is returned * @param onRestore - a function to exec when the restoreIfTrue condition is met */ function patchXHR(restoreIfTrue, onRestore) { var originalXHR = win.XMLHttpRequest.prototype.open; win.XMLHttpRequest.prototype.open = function() { originalXHR.apply(this, arguments); var restore = restoreIfTrue.apply(this, arguments); if (restore) { win.XMLHttpRequest.prototype.open = originalXHR; onRestore(restore); } }; }; scdl.getTrackName = function(trackJSON) { return [ trackJSON.user.username, trackJSON.title ].join(' - '); }; scdl.getMediaURL = function(json, onresolve, onerror) { if (json.media && json.media.transcodings) { var found = json.media.transcodings.filter(function(tc) { return tc.format && tc.format.protocol === 'progressive'; })[0]; if (found) { var xhr = new XMLHttpRequest(); xhr.onload = function() { var result; try { result = JSON.parse(xhr.responseText); } catch (err) {} if (result && result.url) onresolve(result.url); else onerror(false); }; xhr.onerror = onerror; xhr.open('GET', found.url + '?client_id=' + scdl.client_id); xhr.send(); } else { onerror(false); } } else { onerror(false); } }; scdl.getStreamURL = function(url, onresolve, onerror) { var xhr = new XMLHttpRequest(); xhr.onload = function() { var trackJSON = JSON.parse(xhr.responseText); scdl.getMediaURL(trackJSON, function resolve(url) { onresolve({ stream_url: url, track_name: scdl.getTrackName(trackJSON) }); }, function reject() { onerror(false); }) }.bind(this); xhr.onerror = function() { onerror(false); }; xhr.open('GET', 'https://api-v2.soundcloud.com/resolve?url=' + encodeURIComponent(url) + '&client_id=' + this.client_id); xhr.send(); }; scdl.button = { download: function(e) { e.preventDefault(); var dlButton = document.getElementById(scdl.dlButtonId) if (dlButton) { dlButton.textContent = labels.downloading; } setTimeout(function() { saveAs(e.target.href, e.target.dataset.title); if (dlButton) { dlButton.textContent = labels.download; } }, 100) }, render: function(href, title, onClick) { var label = labels.download; var a = document.createElement('a'); a.className = "sc-button sc-button-medium sc-button-responsive sc-button-download"; a.href = href; a.id = scdl.dlButtonId; a.textContent = label; a.title = label; a.dataset.title = title + '.mp3'; a.setAttribute('download', title + '.mp3'); a.target = '_blank'; a.onclick = onClick; a.style.marginLeft = '5px'; a.style.cssFloat = 'left'; a.style.border = '1px solid orangered'; return a; }, attach:function() { var args = arguments, self = this, iterations = 0 // account for rendering delays var intv = setInterval(function() { var f = document.querySelector(containerSelector) iterations++ if (f && !document.getElementById(scdl.dlButtonId)) { f.insertAdjacentElement('beforeend', self.render.apply(self, args)); log('Attaching download button to element:', f) clearInterval(intv) // stop after trying to find the element for 5s } else if (iterations === 50) { log('%c Couldn\'t find element "' + containerSelector + '" after 2 seconds', 'color: #FF0000;') clearInterval(intv) } }, 100) }, remove: function() { var btn = document.getElementById(scdl.dlButtonId); if (btn) btn.parentNode.removeChild(btn); } }; scdl.modal = { providers: [ 'aHR0cHM6Ly9zY2xvdWRkb3dubG9hZGVyLm5ldA==', 'aHR0cHM6Ly93d3cuc291bmRjbG91ZG1wMy5vcmc=', 'aHR0cHM6Ly9zb3VuZGNsb3VkbWUuY29t' ], render: function(title) { var temp = document.createElement('div'), self = this const html = [ '<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">', '<div class="modal__modal sc-border-box g-z-index-modal-content transparentBackground" style="height: auto;">', '<button type="button" title="' + labels.close + '" class="modal__closeButton">' + labels.close + '</button>', '<div class="modal__content"><div class="tabs"><div class="tabs__content"><div class="tabs__contentSlot" style="display: block;"><article class="shareContent">', '<div class="publicShare"><section class="g-modal-section sc-clearfix sc-pt-2x">', '<h2 class="sc-orange">Soundcloud Downloader Clean ' + labels.modal_title + '</h2>', '</section><section class="g-modal-section sc-clearfix sc-pt-2x">', '<h3 style="margin-bottom: 0.5rem;">' + labels.download + ' <em>' + title + '</em> via: </h3>', 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(''), '<div class="shareLink sc-clearfix publicShare__link sc-pt-2x m-showPositionOption" style="margin-top: 1rem;">', '<label for="shareLink__field" style="margin-right:0.5rem;">Link</label>', '<input type="text" value="' + win.location.href + '" class="shareLink__field sc-input" id="shareLink__field" readonly="readonly">', '<button class="sc-button sc-button-copy">' + labels.copy + '</button>', '<span class="sc-copy-feedback" style="margin-left: 1rem;"></span>', '</div>', '</section></div></article></div></div></div></div></div></div>' ].join('') temp.innerHTML = html var cnt = temp.firstElementChild cnt.addEventListener('click', function(e) { if (this === e.target || e.target.classList.contains('modal__closeButton')) { self.remove() } else if (e.target.classList.contains('sc-button-copy')) { navigator.clipboard.writeText(win.location.href) .then(function() { var f = cnt.querySelector('.sc-copy-feedback') f.innerHTML = '<span style="color: green;">Copied to clipboard!</span>' }, function(err) { log('Failed to write URL to the clipboard.', err) var f = cnt.querySelector('.sc-copy-feedback') f.innerHTML = '<span style="color: red;">Failed to copy to clipboard!</span>' }) } }) return cnt }, attach: function() { this.remove() document.body.appendChild(this.render.apply(this, arguments)) }, remove: function() { var modal = document.getElementById(scdl.modalId); if (modal) modal.parentNode.removeChild(modal); } } scdl.parseClientIdFromURL = function(url) { var search = /client_id=([\w\d]+)&*/; return url && url.match(search) && url.match(search)[1]; }; scdl.getClientID = function(onClientIDFound) { patchXHR(function(method, url) { return scdl.parseClientIdFromURL(url); }, onClientIDFound); }; scdl.load = function(url) { // for now only make available for single track pages if (/^(\/(you|stations|discover|stream|upload|search|settings|.+?\/sets))/.test(win.location.pathname)) { scdl.button.remove(); return; } scdl.getStreamURL(url, function onSuccess(result) { if (!result) { scdl.button.remove(); } else { log('Detected valid Soundcloud artist track URL. Requesting info...'); scdl.button.attach( result.stream_url, result.track_name, scdl.button.download ); } }, function onError() { log('%c No compatible media transcoding found.', 'color: #FF0000;'); scdl.button.attach('javascript:void(0);', 'None', function() { var title = document.querySelector('.soundTitle__title') var artist = document.querySelector('.soundTitle__username') scdl.modal.attach([artist.textContent.trim(), '-', title.textContent.trim()].join(' ')) }) } ); }; // patch front-end navigation ['pushState','replaceState','forward','back','go'].forEach(function(event) { var tmp = win.history.pushState; win.history[event] = function() { tmp.apply(win.history, arguments); scdl.load(win.location.href); } }); if (scdl.debug) win.scdl = scdl; scdl.getClientID(function(id) { log('Found Soundcloud client id:', id, '. Initializing...'); scdl.client_id = id; scdl.load(win.location.href); }); })();