Skip to content

Instantly share code, notes, and snippets.

@webketje
Last active May 10, 2024 18:09
Show Gist options
  • Star 21 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save webketje/8cd2e6ae8a86dbe0533c5d2c612c42c6 to your computer and use it in GitHub Desktop.
Save webketje/8cd2e6ae8a86dbe0533c5d2c612c42c6 to your computer and use it in GitHub Desktop.
Soundcloud Downloader Clean - Tampermonkey userscript OR bookmarklet

Tampermonkey userscript - Soundcloud Downloader Clean

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

Features

  • No third-party embeds, redirects or ads, directly uses the Soundcloud API.
  • Works with ad-blocker on.
  • Can be used without Soundcloud account or logged out.
  • Works with all Soundcloud languages.
  • Responsive, blends in with Soundcloud style.
  • To enable debug logging, set debug: true at the start of the userscript.

Caveats

  • Soundcloud Go+ tracks will only download the 30 seconds preview sample.
  • Some tracks on Soundcloud are only provided as AES-encrypted HLS playlist files. Soundcloud Downloader Clean is not able to download these and will instead display a modal with links to third-party Soundcloud downloaders:

2024-01-05 - v1.0.0
- feat: download button on HLS tracks will open a modal with third-party services
- fix: don't display download button on user sets
- fix: wait for soundActions bar to render for 5s
- added: error log formatting
- Updated README.md to include caveats
- Reverse changelog to most recent first
2020-01-14 - v0.2.1
- Removed unnecessary dependency
- Added changelog
2020-01-14 - v0.2
- Updated to Soundcloud API v2
- Use Soundcloud download button style
- Updated screenshot and readme
2019-11-14 - v0.1
- Initial release
// ==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);
});
})();
@xxturboxx
Copy link

I was so excited about this but it doesn't seem to load properly.

@webketje
Copy link
Author

@xxturboxx what browser are you on? For me it works perfectly (Chrome/ Firefox). Try for example this URL https://soundcloud.com/throttle/dreamer

@xxturboxx
Copy link

xxturboxx commented Jan 19, 2020 via email

@TheDarkTron
Copy link

Thanks mate, this one is awesome! Clean and functional as it should be <3

@RAGAGAG
Copy link

RAGAGAG commented Dec 30, 2021

HOW TO DOWNLOAD 320KBPS ON soundcloud?

@webketje
Copy link
Author

@RAGAGAG Last time I worked on this script, you couldn't. There was only 128kbps. Maybe Soundcloud added extra sources, I should have a look.
Another type of music this script cannot download is HLS (streaming) sources (which are m3u files composed of temporary links to chunks of data of a song). I tried to add it but it's too hard, the chunks are encrypted with AES and a private key through a third-party library I cannot obtain access to them

@nascentt
Copy link

Thi script is incredible, thanks for releasing it.

@Cabbasca
Copy link

the saveAs function doesn't work, you can use GM_download. Example:
GM_download({ url: e.target.href, name: e.target.dataset.title, saveAs: true });

@webketje
Copy link
Author

@Cabbasca GM_download only works in the context of TamperMonkey. Filesaver.js also works as a bookmarklet. If it doesn't work for you, that means the page failed to load https://cdn.jsdelivr.net/npm/file-saver@2.0.2/dist/FileSaver.min.js because I just tested and it works as intended.

@nascentt
Copy link

Interestingly this stopped working for me, my script had version 0.2.1 but and greasyfork has 0.2.1 but I found 1.0.0 on openuserjs so updated from there - there was a 90%+ change (@@ -1,214 +1,370 @@)
and now everything works again
i'd strongly recommend updating the script at greasyfork with the downloadurl and updateurl of openuserjs

@webketje
Copy link
Author

webketje commented Mar 1, 2024

@nascentt thx I forgot to update it there, it's ok now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment