Local SoundCloud Downloader

Download SoundCloud without external service.

  1. // ==UserScript==
  2. // @name Local SoundCloud Downloader
  3. // @namespace https://blog.maple3142.net/
  4. // @version 0.1.4
  5. // @description Download SoundCloud without external service.
  6. // @author maple3142
  7. // @match https://soundcloud.com/*
  8. // @require https://cdn.jsdelivr.net/npm/web-streams-polyfill@2.0.2/dist/ponyfill.min.js
  9. // @require https://cdn.jsdelivr.net/npm/streamsaver@2.0.3/StreamSaver.min.js
  10. // @grant none
  11. // @icon https://a-v2.sndcdn.com/assets/images/sc-icons/favicon-2cadd14bdb.ico
  12. // ==/UserScript==
  13.  
  14. streamSaver.mitm = 'https://maple3142.github.io/StreamSaver.js/mitm.html'
  15. function hook(obj, name, callback, type) {
  16. const fn = obj[name]
  17. obj[name] = function (...args) {
  18. if (type === 'before') callback.apply(this, args)
  19. fn.apply(this, args)
  20. if (type === 'after') callback.apply(this, args)
  21. }
  22. return () => {
  23. // restore
  24. obj[name] = fn
  25. }
  26. }
  27. function triggerDownload(url, name) {
  28. const a = document.createElement('a')
  29. document.body.appendChild(a)
  30. a.href = url
  31. a.download = name
  32. a.click()
  33. a.remove()
  34. }
  35. const btn = {
  36. init() {
  37. this.el = document.createElement('button')
  38. this.el.textContent = 'Download'
  39. this.el.classList.add('sc-button')
  40. this.el.classList.add('sc-button-medium')
  41. this.el.classList.add('sc-button-responsive')
  42. this.el.classList.add('sc-button-download')
  43. },
  44. cb() {
  45. const par = document.querySelector('.sc-button-toolbar .sc-button-group')
  46. if (par && this.el.parentElement !== par) par.insertAdjacentElement('beforeend', this.el)
  47. },
  48. attach() {
  49. this.detach()
  50. this.observer = new MutationObserver(this.cb.bind(this))
  51. this.observer.observe(document.body, { childList: true, subtree: true })
  52. this.cb()
  53. },
  54. detach() {
  55. if (this.observer) this.observer.disconnect()
  56. }
  57. }
  58. btn.init()
  59. async function getClientId() {
  60. return new Promise(resolve => {
  61. const restore = hook(
  62. XMLHttpRequest.prototype,
  63. 'open',
  64. async (method, url) => {
  65. const u = new URL(url, document.baseURI)
  66. const clientId = u.searchParams.get('client_id')
  67. if (!clientId) return
  68. console.log('got clientId', clientId)
  69. restore()
  70. resolve(clientId)
  71. },
  72. 'after'
  73. )
  74. })
  75. }
  76. const clientIdPromise = getClientId()
  77. let controller = null
  78. async function load(by) {
  79. btn.detach()
  80. console.log('load by', by, location.href)
  81. if (/^(\/(you|stations|discover|stream|upload|search|settings))/.test(location.pathname)) return
  82. const clientId = await clientIdPromise
  83. if (controller) {
  84. controller.abort()
  85. controller = null
  86. }
  87. controller = new AbortController()
  88. const result = await fetch(
  89. `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(location.href)}&client_id=${clientId}`,
  90. { signal: controller.signal }
  91. ).then(r => r.json())
  92. console.log('result', result)
  93. if (result.kind !== 'track') return
  94. btn.el.onclick = async () => {
  95. const progressive = result.media.transcodings.find(t => t.format.protocol === 'progressive')
  96. if (progressive) {
  97. const { url } = await fetch(progressive.url + `?client_id=${clientId}`).then(r => r.json())
  98. const resp = await fetch(url)
  99. const ws = streamSaver.createWriteStream(result.title + '.mp3', {
  100. size: resp.headers.get('Content-Length')
  101. })
  102. const rs = resp.body
  103. if (rs.pipeTo) {
  104. console.log(rs, ws)
  105. return rs.pipeTo(ws)
  106. }
  107. const reader = rs.getReader()
  108. const writer = ws.getWriter()
  109. const pump = () =>
  110. reader.read().then(res => (res.done ? writer.close() : writer.write(res.value).then(pump)))
  111.  
  112. return pump()
  113. }
  114. alert('Sorry, downloading this music is currently unsupported.')
  115. }
  116. btn.attach()
  117. console.log('attached')
  118. }
  119. load('init')
  120. hook(history, 'pushState', () => load('pushState'), 'after')
  121. window.addEventListener('popstate', () => load('popstate'))