Skip to content

Instantly share code, notes, and snippets.

@webketje
Last active September 3, 2024 23:19
Show Gist options
  • 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/[email protected]/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/[email protected]/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

@sunset4myval
Copy link

sunset4myval commented Jun 22, 2024

How to install this extension?

@webketje
Copy link
Author

@sunset4myval You need to install the Tampermonkey browser extension and then click the "Install" button here: https://openuserjs.org/scripts/webketje/Soundcloud_Downloader_Clean

@AllKillahNoFillah
Copy link

Hi all Im working from mac, very novice to extensions and all this tampermonkey business. I cant get it to operate on my chrome soundcloud url, any suggestions?

@AllKillahNoFillah
Copy link

When I click on tamper monkey extension icon (when visiting Soundcloud), it says no script is running, is this potentially the issue?
Uploading Screenshot 2024-09-03 at 1.04.09 PM.png…

@webketje
Copy link
Author

webketje commented Sep 3, 2024

@AllKillahNoFillah if no script is running in Tampermonkey, you haven't properly installed or enabled this script.

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