SoundCloud Downloader

Adds a direct download button to all the tracks on SoundCloud (works with the new SoundCloud interface)

  1. // jshint browser: true, jquery: true
  2. // ==UserScript==
  3. // @name SoundCloud Downloader
  4. // @namespace http://www.dieterholvoet.com
  5. // @author Dieter Holvoet
  6. // @description Adds a direct download button to all the tracks on SoundCloud (works with the new SoundCloud interface)
  7. // @require https://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js
  8. // @include http://www.soundcloud.com/*
  9. // @include http://soundcloud.com/*
  10. // @include https://www.soundcloud.com/*
  11. // @include https://soundcloud.com/*
  12. // @grant GM_addStyle
  13. // @grant GM_openInTab
  14. // @version 1.2
  15. // ==/UserScript==
  16. //-----------------------------------------------------------------------------------
  17.  
  18. jQuery.noConflict();
  19. (function ($) {
  20.  
  21. $(function () {
  22.  
  23. $.fn.exists = function () {
  24. return this.length !== 0;
  25. };
  26.  
  27. /** Client ID **/
  28. var clientId = 'DQskPX1pntALRzMp4HSxya3Mc0AO66Ro';
  29.  
  30. /** Append stylesheet */
  31. var icon_buy = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAQAAACR313BAAAAg0lEQVQY02NgIAzU/qtuxSP9/4zOdxkhPAqcPqr9R4Uo5gXdRZd2eIwknXoAXXrZJiTp0tmokl6f/hsjSXfm4dHLwLDZUv+3y5fYN5VPZ907cO3leRS9QK/x/d/7f8H/5v9p/z3+a//nw/Ca6laYVxAsJOB6z/UeOgvZ+An/J6CzKAUAAatiLSilSl4AAAAASUVORK5CYII=";
  32.  
  33. GM_addStyle(
  34. ".sc-button-small.sc-button-buy:before, .sc-button-medium.sc-button-buy:before {" +
  35. "background-image: url("+icon_buy+")" +
  36. "}"
  37. );
  38.  
  39. /** Append download buttons */
  40. setInterval(function () {
  41.  
  42. /**
  43. * Playlist page
  44. * - with official downloads: e.g. https://soundcloud.com/alexdoanofficial/sets/into-the-void-ep
  45. * - without official downloads: e.g. https://soundcloud.com/mule-z/sets/tropical
  46. * */
  47.  
  48. $(".trackList").find(".trackList__item").each(function () {
  49. var $item = $(this).find(".trackItem__trackTitle").eq(0),
  50. data = {
  51. title: cleanTitle($item.text()),
  52. url: $item.attr("href"),
  53. id: "" // TODO: Fetch ID
  54. },
  55. track = new SoundCloudTrack($(this), data);
  56.  
  57. track.appendButton('small', true);
  58. });
  59.  
  60.  
  61. /**
  62. * Track page
  63. * e.g. https://soundcloud.com/mkjaff/dyrisk-the-tallest-man-mkj-remix
  64. * */
  65.  
  66. if($(".listenDetails .commentsList").exists()) {
  67. var $el = ($(".listenEngagement__footer").exists() ? $(".listenEngagement__footer") : $(".sound__footer")),
  68. data = {
  69. title: cleanTitle($(".soundTitle__title").eq(0).text()),
  70. url: document.location.href,
  71. id: "" // TODO: Fetch ID
  72. },
  73. track = new SoundCloudTrack($el, data);
  74.  
  75. track.appendButton('medium', false);
  76. }
  77.  
  78.  
  79. /**
  80. * Homepage
  81. * https://soundcloud.com/stream
  82. *
  83. * Likes
  84. * https://soundcloud.com/you/likes
  85. *
  86. * Overview
  87. * https://soundcloud.com/you/collection
  88. *
  89. * User tracks page
  90. * https://soundcloud.com/lexer/tracks
  91. * */
  92.  
  93. $(".lazyLoadingList").find(".soundList__item").each(function () {
  94. if (!$(this).find(".sound").is(".playlist")) {
  95. var data = {
  96. title: cleanTitle($(this).find(".soundTitle__title").eq(0).text()),
  97. url: $(this).find(".soundTitle__title").eq(0).attr("href"),
  98. id: ""
  99. },
  100. track = new SoundCloudTrack($(this), data);
  101.  
  102. track.appendButton('small', false);
  103. }
  104. });
  105.  
  106.  
  107. /**
  108. * Charts
  109. * e.g. https://soundcloud.com/charts/top
  110. * */
  111.  
  112. $(".chartTracks").find(".chartTracks__item > .chartTrack").each(function () {
  113. var $item = $(this).find(".chartTrack__title a").eq(0),
  114. data = {
  115. title: $item.text(),
  116. url: $item.attr("href")
  117. },
  118. track = new SoundCloudTrack($(this), data);
  119.  
  120. track.appendButton('small', true);
  121. });
  122.  
  123.  
  124. /**
  125. * Play history
  126. * https://soundcloud.com/you/history
  127. * */
  128.  
  129. $(".historicalPlays").find(".historicalPlays__item").each(function () {
  130. var $item = $(this).find("a.soundTitle__title").eq(0),
  131. data = {
  132. title: cleanTitle($item.text()),
  133. url: $item.attr("href")
  134. },
  135. track = new SoundCloudTrack($(this), data);
  136.  
  137. track.appendButton('small', false);
  138. });
  139.  
  140.  
  141. /**
  142. * User profile page
  143. * e.g. https://soundcloud.com/kiyokomusik
  144. * */
  145.  
  146. $(".userStream").find(".soundList__item > .userStreamItem").each(function () {
  147. if (!$(this).find(".sound").is(".playlist")) {
  148. var data = {
  149. title: cleanTitle($(this).find(".soundTitle__title").eq(0).text()),
  150. url: $(this).find(".soundTitle__title").eq(0).attr("href")
  151. },
  152. track = new SoundCloudTrack($(this), data);
  153.  
  154. track.appendButton('small', false);
  155. }
  156. });
  157.  
  158.  
  159. /**
  160. * Search page
  161. * e.g. https://soundcloud.com/search?q=addal
  162. * */
  163.  
  164. $(".searchList").find(".searchList__item").each(function () {
  165. if ($(this).find(".sound").is(".track")) {
  166. var $item = $(this).find(".soundTitle__title").eq(0),
  167. data = {
  168. title: $item.text(),
  169. url: $item.attr("href")
  170. },
  171. track = new SoundCloudTrack($(this), data);
  172.  
  173. track.appendButton('small', false);
  174.  
  175. } else if ($(this).find(".sound").is(".playlist")) {
  176. // TO DO: Download playlist
  177. }
  178. });
  179.  
  180. }, 2000);
  181.  
  182. function SoundCloudGritter(track, title, isError, timeout) {
  183. var $wrapper = $("#gritter-notice-wrapper"),
  184. $gritters = $(".gritter-item-wrapper"),
  185. id = 'gritter-item-'+$gritters.length+1,
  186. $gritter = $('<div id="'+id+'" class="gritter-item-wrapper'+(isError ? ' error' : '')+'"><div class="gritter-top"></div><div class="gritter-item"><div class="gritter-close" style="display: none;"></div>'+(isError && track.findID() ? '' : '<img src="'+track.getArtworkURL(50)+'" class="gritter-image">')+'<div class="gritter-with-image">'+title+'<div style="clear:both"></div></div><div class="gritter-bottom"></div></div>');
  187.  
  188. if(!$wrapper.exists()) {
  189. $(document).find('body').append('<div id="gritter-notice-wrapper" class="top-right"></div>');
  190. $wrapper = $($wrapper.selector)
  191. }
  192.  
  193. $wrapper.append($gritter);
  194.  
  195. setTimeout(function() {
  196. $("#"+id).fadeOut();
  197. }, timeout);
  198. }
  199.  
  200. function SoundCloudTrack($el, data) {
  201.  
  202. // Set $el
  203. this.$el = $el;
  204.  
  205. // Set title
  206. if('title' in data)
  207. this.title = cleanTitle(data.title);
  208. else
  209. console.error("Missing title.", this);
  210.  
  211. // Set url
  212. if('url' in data && isValidTrackURL(cleanURL(data.url)))
  213. this.url = cleanURL(data.url);
  214. else
  215. console.error("Missing or invalid track url.", this);
  216.  
  217. // Set id
  218. data.id = this.findID();
  219.  
  220. if('id' in data)
  221. this.id = data.id;
  222. else
  223. console.error("Couldn't find the ID of this song: ", this);
  224. }
  225.  
  226. SoundCloudTrack.prototype.findButtonGroup = function() {
  227. var $small = this.$el.find('.soundActions .sc-button-group-small'),
  228. $medium = this.$el.find('.soundActions .sc-button-group-medium');
  229.  
  230. if($small.exists()) {
  231. return $small;
  232.  
  233. } else if($medium.exists()) {
  234. return $medium;
  235.  
  236. } else {
  237. return false;
  238. }
  239. };
  240.  
  241. SoundCloudTrack.prototype.findID = function() {
  242. if(exists(this.id))
  243. return this.id;
  244.  
  245. var id = false,
  246. track = this;
  247.  
  248. this.$el.find(".sc-artwork").each(function() {
  249. var bg = $(this).css("background-image"),
  250. results = /artworks-([a-zA-Z0-9]+)-/.exec(bg);
  251.  
  252. if(results != null && results.length > 0) {
  253. id = results[1];
  254. }
  255. });
  256.  
  257. if(id) {
  258. this.id = id;
  259. }
  260.  
  261. return id;
  262. };
  263.  
  264. SoundCloudTrack.prototype.makeDownloadButton = function(url, size, isIconOnly, isExternal) {
  265. var $button = $('<a class="sc-button sc-button-'+size+' sc-button-responsive sc-button-download'+(isIconOnly ? ' sc-button-icon' : '')+'" sc-id="'+this.id+'" title="Download ' + this.title + '" >Download'+ (isExternal ? ' (external)' : '') +'</a>'),
  266. track = this;
  267.  
  268. // Remove exit.sc from URL
  269. if(url.indexOf("exit.sc") !== -1) {
  270. url = (new URL(url).search.match(/(?:\?|&)url=([^&]+)/) || [])[1];
  271. url = decodeURIComponent(url);
  272. }
  273.  
  274. if(!isExternal && isValidTrackURL(url)) {
  275. url = "https://api.soundcloud.com/resolve.json?client_id=" + clientId + "&url=" + url;
  276.  
  277. $button.on("click", function() {
  278. var id = $(this).attr('sc-id');
  279. if(exists(id))
  280. new SoundCloudGritter(track, 'Download of <span class="gritter-title">'+track.title+'</span> will start in a moment.</div>', false, 3000);
  281.  
  282. $.get(url, function (data) {
  283. if (data.hasOwnProperty('error') || !data.hasOwnProperty('stream_url')) {
  284. var message = data.error;
  285.  
  286. if(track.isGeoblocked())
  287. message = "not available in your country.";
  288. else if(track.isGO())
  289. message = "only for SoundCloud GO users.";
  290.  
  291. new SoundCloudGritter(track, 'Download of <span class="gritter-title">'+track.title+'</span> failed: '+message, true, 8000);
  292. console.error("Download failed: " + message, track);
  293.  
  294. } else {
  295. downloadUrl(data.stream_url + '?client_id=' + clientId);
  296. }
  297. }, "json");
  298. });
  299.  
  300. } else {
  301. $button.attr("href", url);
  302. $button.attr("target", '_blank');
  303. }
  304.  
  305. this.findButtonGroup().eq(0).append($button);
  306. return $button;
  307. };
  308.  
  309. SoundCloudTrack.prototype.makeBuyButton = function(url, size, iconOnly) {
  310. var $button = $('<a href="'+url+'" target="_blank" class="sc-button sc-button-'+size+' sc-button-responsive sc-button-buy'+(iconOnly ? ' sc-button-icon' : '')+'" title="Buy ' + this.title + '" >Buy</a>');
  311. this.findButtonGroup().eq(0).append($button);
  312. return $button;
  313. };
  314.  
  315. SoundCloudTrack.prototype.appendButton = function(size, iconOnly) {
  316.  
  317. // Set checked
  318. if(this.isChecked())
  319. return;
  320. else
  321. this.setChecked(true);
  322.  
  323. /** Find and check button group **/
  324. if(!this.findButtonGroup()) {
  325. if(this.isPreview()) {
  326. console.error("Track is preview-only, can't be downloaded: " + url);
  327.  
  328. } else if(this.isGeoblocked()) {
  329. console.error("Track is geoblocked, can't be downloaded: " + url);
  330.  
  331. } else {
  332. console.error("No button-group found. Please verify selector.");
  333. }
  334.  
  335. return;
  336. }
  337.  
  338. // Append download button
  339. if(this.findButtonGroup().find(".sc-button-download").exists()) {
  340. /** Check presence of download button */
  341. // console.error("Download button already present.");
  342.  
  343. } else if(this.findExternalFreeDownload()) {
  344. /** Check presence of external free download link */
  345. this.makeDownloadButton(this.findExternalFreeDownload().prop('href'), size, iconOnly, true);
  346. this.findExternalFreeDownload().remove();
  347.  
  348. } else {
  349. /** Fetch download URL */
  350. this.makeDownloadButton(this.url, size, iconOnly, false);
  351. }
  352.  
  353. // Append buy button
  354. var $external = this.findExternalBuyLink();
  355. if($external) {
  356. this.makeBuyButton($external.prop('href'), size, iconOnly);
  357. $external.remove();
  358. }
  359. };
  360.  
  361. SoundCloudTrack.prototype.findExternalFreeDownload = function() {
  362. if(exists(this.$freedl))
  363. return this.$freedl;
  364.  
  365. var $freedl = this.$el.parent().find('.soundActions__purchaseLink').eq(0),
  366. strings = ['free download', 'free dl'],
  367. websites = ['theartistunion', 'toneden', 'artistsunlimited.co', 'melodicsoundsnetwork.com', 'edmlead.net', 'click.dj', 'woox.agency', 'hypeddit.com', 'hive.co'],
  368. hasExternalFreeDownload = false;
  369.  
  370. if($freedl.exists()) {
  371. strings.forEach(function(elem) {
  372. if($freedl.text().toLowerCase().indexOf(elem) !== -1)
  373. hasExternalFreeDownload = true;
  374. });
  375.  
  376. websites.forEach(function(elem) {
  377. if($freedl.attr('href').toLowerCase().indexOf(elem) !== -1)
  378. hasExternalFreeDownload = true;
  379. });
  380. }
  381.  
  382. if(hasExternalFreeDownload) {
  383. this.$freedl = $freedl;
  384. return $freedl;
  385.  
  386. } else {
  387. return false;
  388. }
  389. };
  390.  
  391. SoundCloudTrack.prototype.findExternalBuyLink = function() {
  392. var $buylink = this.$el.find('.soundActions__purchaseLink').eq(0),
  393. strings = ['buy', 'spotify', 'beatport', 'juno', 'stream'],
  394. websites = ['lnk.to', 'open.spotify.com', 'spoti.fi', 'junodownload.com', 'beatport.com', 'itunes.apple.com', 'play.google.com', 'deezer.com', 'napster.com', 'music.microsoft.com'],
  395. hasExternalBuyLink = false;
  396.  
  397. if($buylink.exists()) {
  398. strings.forEach(function(elem) {
  399. if($buylink.text().toLowerCase().indexOf(elem) !== -1)
  400. hasExternalBuyLink = true;
  401. });
  402.  
  403. websites.forEach(function(elem) {
  404. if($buylink.attr('href').toLowerCase().indexOf(elem) !== -1)
  405. hasExternalBuyLink = true;
  406. });
  407. }
  408.  
  409. if(hasExternalBuyLink) {
  410. return $buylink;
  411.  
  412. } else {
  413. return false;
  414. }
  415. };
  416.  
  417. SoundCloudTrack.prototype.setChecked = function(checked) {
  418. this.$el.attr('checked', checked);
  419. };
  420.  
  421. SoundCloudTrack.prototype.isChecked = function(checked) {
  422. return typeof this.$el.attr('checked') != 'undefined';
  423. };
  424.  
  425. SoundCloudTrack.prototype.isGeoblocked = function() {
  426. if(this.$el.find(".g-geoblocked-icon").exists()) {
  427. return true;
  428.  
  429. } else if(this.$el.parent("trackItem__additional").find(".g-geoblocked-icon").exists()) {
  430. return true;
  431. }
  432.  
  433. return false;
  434. };
  435.  
  436. SoundCloudTrack.prototype.isPreview = function() {
  437. var $item = $([]);
  438.  
  439. if(this.$el.find(".sc-snippet-badge").exists()) {
  440. $item = $item.find(".sc-snippet-badge");
  441.  
  442. } else if(this.$el.parent("trackItem__additional").find(".sc-snippet-badge").exists()) {
  443. $item = $item.parent("trackItem__additional").find(".sc-snippet-badge");
  444. }
  445.  
  446. return $item.eq(0).text() === "Preview";
  447. };
  448.  
  449. SoundCloudTrack.prototype.isGO = function() {
  450. return this.$el.find('.g-go-marker-artwork').exists();
  451. };
  452.  
  453. SoundCloudTrack.prototype.getArtworkURL = function(size) {
  454. return 'https://i1.sndcdn.com/artworks-'+this.id+'-0-t'+size+'x'+size+'.jpg'
  455. };
  456.  
  457. /*
  458. HELPERS
  459. */
  460.  
  461. function exists(thing) {
  462. return (typeof thing != "undefined" || thing != null || ($.isArray(thing) && thing.length > 0))
  463. }
  464.  
  465. function cleanTitle(title) {
  466. title = title.replace(/"/g, "'");
  467. title = $.trim(title);
  468. return title;
  469. }
  470.  
  471. function cleanURL(url) {
  472. url = url.split(/[?#]/)[0]; // Strip query string
  473. url = relativeToAbsoluteURL(url); // Convert to an absolute url if necessary
  474. return url;
  475. }
  476.  
  477. function isValidTrackURL(url) {
  478. if(!url.match(/^(http|https):\/\/soundcloud\.com\/.+\/.+$/g)) return false;
  479. if(url.match(/^(http|https):\/\/soundcloud\.com\/.+\/sets\/.+$/)) return false;
  480. return true;
  481. }
  482.  
  483. function relativeToAbsoluteURL(url) {
  484. if(url.substr(0, 1) === '/')
  485. return 'https://soundcloud.com'+url;
  486. else
  487. return url;
  488. }
  489.  
  490. function downloadUrl(url) {
  491. if (!$('.js-downloader').exists()) {
  492. $('body').append('<a class="js-downloader" style="visibility: hidden; position: absolute"></a>');
  493. }
  494.  
  495. var $downloader = $('.js-downloader');
  496. $downloader.attr('href', url);
  497. $downloader.attr('download', 'download');
  498. $downloader[0].click();
  499. }
  500.  
  501. });
  502.  
  503. })(jQuery);