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.5
  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-icon')
  42. this.el.classList.add('sc-button-responsive')
  43. this.el.classList.add('sc-button-secondary')
  44. this.el.classList.add('sc-button-download')
  45. },
  46. cb() {
  47. const par = document.querySelector('.sc-button-toolbar .sc-button-group')
  48. if (par && this.el.parentElement !== par) par.insertAdjacentElement('beforeend', this.el)
  49. },
  50. attach() {
  51. this.detach()
  52. this.observer = new MutationObserver(this.cb.bind(this))
  53. this.observer.observe(document.body, { childList: true, subtree: true })
  54. this.cb()
  55. },
  56. detach() {
  57. if (this.observer) this.observer.disconnect()
  58. }
  59. }
  60. btn.init()
  61. async function getClientId() {
  62. return new Promise(resolve => {
  63. const restore = hook(
  64. XMLHttpRequest.prototype,
  65. 'open',
  66. async (method, url) => {
  67. const u = new URL(url, document.baseURI)
  68. const clientId = u.searchParams.get('client_id')
  69. if (!clientId) return
  70. console.log('got clientId', clientId)
  71. restore()
  72. resolve(clientId)
  73. },
  74. 'after'
  75. )
  76. })
  77. }
  78. const clientIdPromise = getClientId()
  79. let controller = null
  80. async function load(by) {
  81. btn.detach()
  82. console.log('load by', by, location.href)
  83. if (/^(\/(you|stations|discover|stream|upload|search|settings))/.test(location.pathname)) return
  84. const clientId = await clientIdPromise
  85. if (controller) {
  86. controller.abort()
  87. controller = null
  88. }
  89. controller = new AbortController()
  90. const result = await fetch(
  91. `https://api-v2.soundcloud.com/resolve?url=${encodeURIComponent(location.href)}&client_id=${clientId}`,
  92. { signal: controller.signal }
  93. ).then(r => r.json())
  94. console.log('result', result)
  95. if (result.kind !== 'track') return
  96. btn.el.onclick = async () => {
  97. const progressive = result.media.transcodings.find(t => t.format.protocol === 'progressive')
  98. if (progressive) {
  99. const { url } = await fetch(progressive.url + `?client_id=${clientId}`).then(r => r.json())
  100. const resp = await fetch(url)
  101. const ws = streamSaver.createWriteStream(result.title + '.mp3', {
  102. size: resp.headers.get('Content-Length')
  103. })
  104. const rs = resp.body
  105. if (rs.pipeTo) {
  106. console.log(rs, ws)
  107. return rs.pipeTo(ws)
  108. }
  109. const reader = rs.getReader()
  110. const writer = ws.getWriter()
  111. const pump = () =>
  112. reader.read().then(res => (res.done ? writer.close() : writer.write(res.value).then(pump)))
  113.  
  114. return pump()
  115. }
  116. alert('Sorry, downloading this music is currently unsupported.')
  117. }
  118. btn.attach()
  119. console.log('attached')
  120. }
  121. load('init')
  122. hook(history, 'pushState', () => load('pushState'), 'after')
  123. window.addEventListener('popstate', () => load('popstate'))