|
// ==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); |
|
}); |
|
})(); |
I was so excited about this but it doesn't seem to load properly.