SoundCloud Downloader

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

// jshint browser: true, jquery: true
// ==UserScript==
// @name        SoundCloud Downloader
// @namespace	http://www.dieterholvoet.com
// @author	    Dieter Holvoet
// @description	Adds a direct download button to all the tracks on SoundCloud  (works with the new SoundCloud interface)
// @require     https://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js
// @include	    http://www.soundcloud.com/*
// @include	    http://soundcloud.com/*
// @include	    https://www.soundcloud.com/*
// @include	    https://soundcloud.com/*
// @grant       GM_addStyle
// @grant       GM_openInTab
// @version	1.2
// ==/UserScript==
//-----------------------------------------------------------------------------------

jQuery.noConflict();
(function ($) {

    $(function () {

        $.fn.exists = function () {
            return this.length !== 0;
        };

        /** Client ID **/
        var clientId = 'DQskPX1pntALRzMp4HSxya3Mc0AO66Ro';

        /** Append stylesheet */
        var icon_buy = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAQAAACR313BAAAAg0lEQVQY02NgIAzU/qtuxSP9/4zOdxkhPAqcPqr9R4Uo5gXdRZd2eIwknXoAXXrZJiTp0tmokl6f/hsjSXfm4dHLwLDZUv+3y5fYN5VPZ907cO3leRS9QK/x/d/7f8H/5v9p/z3+a//nw/Ca6laYVxAsJOB6z/UeOgvZ+An/J6CzKAUAAatiLSilSl4AAAAASUVORK5CYII=";

        GM_addStyle(
            ".sc-button-small.sc-button-buy:before, .sc-button-medium.sc-button-buy:before {" +
            "background-image: url("+icon_buy+")" +
            "}"
        );

        /** Append download buttons */
        setInterval(function () {

            /**
             * Playlist page
             * - with official downloads: e.g. https://soundcloud.com/alexdoanofficial/sets/into-the-void-ep
             * - without official downloads: e.g. https://soundcloud.com/mule-z/sets/tropical
             * */

            $(".trackList").find(".trackList__item").each(function () {
                var $item = $(this).find(".trackItem__trackTitle").eq(0),
                    data = {
                        title: cleanTitle($item.text()),
                        url: $item.attr("href"),
                        id: "" // TODO: Fetch ID
                    },
                    track = new SoundCloudTrack($(this), data);

                track.appendButton('small', true);
            });


            /**
             * Track page
             * e.g. https://soundcloud.com/mkjaff/dyrisk-the-tallest-man-mkj-remix
             * */

            if($(".listenDetails .commentsList").exists()) {
                var $el = ($(".listenEngagement__footer").exists() ? $(".listenEngagement__footer") : $(".sound__footer")),
                    data = {
                        title: cleanTitle($(".soundTitle__title").eq(0).text()),
                        url: document.location.href,
                        id: "" // TODO: Fetch ID
                    },
                    track = new SoundCloudTrack($el, data);

                track.appendButton('medium', false);
            }


            /**
             * Homepage
             * https://soundcloud.com/stream
             *
             * Likes
             * https://soundcloud.com/you/likes
             *
             * Overview
             * https://soundcloud.com/you/collection
             *
             * User tracks page
             * https://soundcloud.com/lexer/tracks
             * */

            $(".lazyLoadingList").find(".soundList__item").each(function () {
                if (!$(this).find(".sound").is(".playlist")) {
                    var data = {
                            title: cleanTitle($(this).find(".soundTitle__title").eq(0).text()),
                            url: $(this).find(".soundTitle__title").eq(0).attr("href"),
                            id: ""
                        },
                        track = new SoundCloudTrack($(this), data);

                    track.appendButton('small', false);
                }
            });


            /**
             * Charts
             * e.g. https://soundcloud.com/charts/top
             * */

            $(".chartTracks").find(".chartTracks__item > .chartTrack").each(function () {
                var $item = $(this).find(".chartTrack__title a").eq(0),
                    data = {
                        title: $item.text(),
                        url: $item.attr("href")
                    },
                    track = new SoundCloudTrack($(this), data);

                track.appendButton('small', true);
            });


            /**
             * Play history
             * https://soundcloud.com/you/history
             * */

            $(".historicalPlays").find(".historicalPlays__item").each(function () {
                var $item = $(this).find("a.soundTitle__title").eq(0),
                    data = {
                        title: cleanTitle($item.text()),
                        url: $item.attr("href")
                    },
                    track = new SoundCloudTrack($(this), data);

                track.appendButton('small', false);
            });


            /**
             * User profile page
             * e.g. https://soundcloud.com/kiyokomusik
             * */

            $(".userStream").find(".soundList__item > .userStreamItem").each(function () {
                if (!$(this).find(".sound").is(".playlist")) {
                    var data = {
                            title: cleanTitle($(this).find(".soundTitle__title").eq(0).text()),
                            url: $(this).find(".soundTitle__title").eq(0).attr("href")
                        },
                        track = new SoundCloudTrack($(this), data);

                    track.appendButton('small', false);
                }
            });


            /**
             * Search page
             * e.g. https://soundcloud.com/search?q=addal
             * */

            $(".searchList").find(".searchList__item").each(function () {
                if ($(this).find(".sound").is(".track")) {
                    var $item = $(this).find(".soundTitle__title").eq(0),
                        data = {
                            title: $item.text(),
                            url: $item.attr("href")
                        },
                        track = new SoundCloudTrack($(this), data);

                    track.appendButton('small', false);

                } else if ($(this).find(".sound").is(".playlist")) {
                    // TO DO: Download playlist
                }
            });

        }, 2000);

        function SoundCloudGritter(track, title, isError, timeout) {
            var $wrapper = $("#gritter-notice-wrapper"),
                $gritters = $(".gritter-item-wrapper"),
                id = 'gritter-item-'+$gritters.length+1,
                $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>');

            if(!$wrapper.exists()) {
                $(document).find('body').append('<div id="gritter-notice-wrapper" class="top-right"></div>');
                $wrapper = $($wrapper.selector)
            }

            $wrapper.append($gritter);

            setTimeout(function() {
                $("#"+id).fadeOut();
            }, timeout);
        }

        function SoundCloudTrack($el, data) {

            // Set $el
            this.$el = $el;

            // Set title
            if('title' in data)
                this.title = cleanTitle(data.title);
            else
                console.error("Missing title.", this);

            // Set url
            if('url' in data && isValidTrackURL(cleanURL(data.url)))
                this.url = cleanURL(data.url);
            else
                console.error("Missing or invalid track url.", this);

            // Set id
            data.id = this.findID();

            if('id' in data)
                this.id = data.id;
            else
                console.error("Couldn't find the ID of this song: ", this);
        }

        SoundCloudTrack.prototype.findButtonGroup = function() {
            var $small = this.$el.find('.soundActions .sc-button-group-small'),
                $medium = this.$el.find('.soundActions .sc-button-group-medium');

            if($small.exists()) {
                return $small;

            } else if($medium.exists()) {
                return $medium;

            } else {
                return false;
            }
        };

        SoundCloudTrack.prototype.findID = function() {
            if(exists(this.id))
                return this.id;

            var id = false,
                track = this;

            this.$el.find(".sc-artwork").each(function() {
                var bg = $(this).css("background-image"),
                    results = /artworks-([a-zA-Z0-9]+)-/.exec(bg);

                if(results != null && results.length > 0) {
                    id = results[1];
                }
            });

            if(id) {
                this.id = id;
            }

            return id;
        };

        SoundCloudTrack.prototype.makeDownloadButton = function(url, size, isIconOnly, isExternal) {
            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>'),
                track = this;

            // Remove exit.sc from URL
            if(url.indexOf("exit.sc") !== -1) {
                url = (new URL(url).search.match(/(?:\?|&)url=([^&]+)/) || [])[1];
                url = decodeURIComponent(url);
            }

            if(!isExternal && isValidTrackURL(url)) {
                url = "https://api.soundcloud.com/resolve.json?client_id=" + clientId + "&url=" + url;

                $button.on("click", function() {
                    var id = $(this).attr('sc-id');
                    if(exists(id))
                        new SoundCloudGritter(track, 'Download of <span class="gritter-title">'+track.title+'</span> will start in a moment.</div>', false, 3000);

                    $.get(url, function (data) {
                        if (data.hasOwnProperty('error') || !data.hasOwnProperty('stream_url')) {
                            var message = data.error;

                            if(track.isGeoblocked())
                                message = "not available in your country.";
                            else if(track.isGO())
                                message = "only for SoundCloud GO users.";

                            new SoundCloudGritter(track, 'Download of <span class="gritter-title">'+track.title+'</span> failed: '+message, true, 8000);
                            console.error("Download failed: " + message, track);

                        } else {
                            downloadUrl(data.stream_url + '?client_id=' + clientId);
                        }
                    }, "json");
                });

            } else {
                $button.attr("href", url);
                $button.attr("target", '_blank');
            }

            this.findButtonGroup().eq(0).append($button);
            return $button;
        };

        SoundCloudTrack.prototype.makeBuyButton = function(url, size, iconOnly) {
            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>');
            this.findButtonGroup().eq(0).append($button);
            return $button;
        };

        SoundCloudTrack.prototype.appendButton = function(size, iconOnly) {

            // Set checked
            if(this.isChecked())
                return;
            else
                this.setChecked(true);

            /** Find and check button group **/
            if(!this.findButtonGroup()) {
                if(this.isPreview()) {
                    console.error("Track is preview-only, can't be downloaded: " + url);

                } else if(this.isGeoblocked()) {
                    console.error("Track is geoblocked, can't be downloaded: " + url);

                } else {
                    console.error("No button-group found. Please verify selector.");
                }

                return;
            }

            // Append download button
            if(this.findButtonGroup().find(".sc-button-download").exists()) {
                /** Check presence of download button */
                // console.error("Download button already present.");

            } else if(this.findExternalFreeDownload()) {
                /** Check presence of external free download link */
                this.makeDownloadButton(this.findExternalFreeDownload().prop('href'), size, iconOnly, true);
                this.findExternalFreeDownload().remove();

            } else {
                /** Fetch download URL */
                this.makeDownloadButton(this.url, size, iconOnly, false);
            }

            // Append buy button
            var $external = this.findExternalBuyLink();
            if($external) {
                this.makeBuyButton($external.prop('href'), size, iconOnly);
                $external.remove();
            }
        };

        SoundCloudTrack.prototype.findExternalFreeDownload = function() {
            if(exists(this.$freedl))
                return this.$freedl;

            var $freedl = this.$el.parent().find('.soundActions__purchaseLink').eq(0),
                strings = ['free download', 'free dl'],
                websites = ['theartistunion', 'toneden', 'artistsunlimited.co', 'melodicsoundsnetwork.com', 'edmlead.net', 'click.dj', 'woox.agency', 'hypeddit.com', 'hive.co'],
                hasExternalFreeDownload = false;

            if($freedl.exists()) {
                strings.forEach(function(elem) {
                    if($freedl.text().toLowerCase().indexOf(elem) !== -1)
                        hasExternalFreeDownload = true;
                });

                websites.forEach(function(elem) {
                    if($freedl.attr('href').toLowerCase().indexOf(elem) !== -1)
                        hasExternalFreeDownload = true;
                });
            }

            if(hasExternalFreeDownload) {
                this.$freedl = $freedl;
                return $freedl;

            } else {
                return false;
            }
        };

        SoundCloudTrack.prototype.findExternalBuyLink = function() {
            var $buylink = this.$el.find('.soundActions__purchaseLink').eq(0),
                strings = ['buy', 'spotify', 'beatport', 'juno', 'stream'],
                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'],
                hasExternalBuyLink = false;

            if($buylink.exists()) {
                strings.forEach(function(elem) {
                    if($buylink.text().toLowerCase().indexOf(elem) !== -1)
                        hasExternalBuyLink = true;
                });

                websites.forEach(function(elem) {
                    if($buylink.attr('href').toLowerCase().indexOf(elem) !== -1)
                        hasExternalBuyLink = true;
                });
            }

            if(hasExternalBuyLink) {
                return $buylink;

            } else {
                return false;
            }
        };

        SoundCloudTrack.prototype.setChecked = function(checked) {
            this.$el.attr('checked', checked);
        };

        SoundCloudTrack.prototype.isChecked = function(checked) {
            return typeof this.$el.attr('checked') != 'undefined';
        };

        SoundCloudTrack.prototype.isGeoblocked = function() {
            if(this.$el.find(".g-geoblocked-icon").exists()) {
                return true;

            } else if(this.$el.parent("trackItem__additional").find(".g-geoblocked-icon").exists()) {
                return true;
            }

            return false;
        };

        SoundCloudTrack.prototype.isPreview = function() {
            var $item = $([]);

            if(this.$el.find(".sc-snippet-badge").exists()) {
                $item = $item.find(".sc-snippet-badge");

            } else if(this.$el.parent("trackItem__additional").find(".sc-snippet-badge").exists()) {
                $item = $item.parent("trackItem__additional").find(".sc-snippet-badge");
            }

            return $item.eq(0).text() === "Preview";
        };

        SoundCloudTrack.prototype.isGO = function() {
            return this.$el.find('.g-go-marker-artwork').exists();
        };

        SoundCloudTrack.prototype.getArtworkURL = function(size) {
            return 'https://i1.sndcdn.com/artworks-'+this.id+'-0-t'+size+'x'+size+'.jpg'
        };

        /*
         HELPERS
         */

        function exists(thing) {
            return (typeof thing != "undefined" || thing != null || ($.isArray(thing) && thing.length > 0))
        }

        function cleanTitle(title) {
            title = title.replace(/"/g, "'");
            title = $.trim(title);
            return title;
        }

        function cleanURL(url) {
            url = url.split(/[?#]/)[0]; // Strip query string
            url = relativeToAbsoluteURL(url); // Convert to an absolute url if necessary
            return url;
        }

        function isValidTrackURL(url) {
            if(!url.match(/^(http|https):\/\/soundcloud\.com\/.+\/.+$/g)) return false;
            if(url.match(/^(http|https):\/\/soundcloud\.com\/.+\/sets\/.+$/)) return false;
            return true;
        }

        function relativeToAbsoluteURL(url) {
            if(url.substr(0, 1) === '/')
                return 'https://soundcloud.com'+url;
            else
                return url;
        }

        function downloadUrl(url) {
            if (!$('.js-downloader').exists()) {
                $('body').append('<a class="js-downloader" style="visibility: hidden; position: absolute"></a>');
            }

            var $downloader = $('.js-downloader');
            $downloader.attr('href', url);
            $downloader.attr('download', 'download');
            $downloader[0].click();
        }

    });

})(jQuery);