Skip to content
Snippets Groups Projects
SoundcloudScript.js 20.8 KiB
Newer Older
Koen's avatar
Koen committed
//* Constants
const API_URL = 'https://api-v2.soundcloud.com/'
const APP_LOCALE = 'en'
const PLATFORM = 'Soundcloud'
Kelvin's avatar
Kelvin committed
const PLATFORM_CLAIMTYPE = 16;
Koen's avatar
Koen committed
const SOUNDCLOUD_APP_VERSION = '1686222762'
const USER_AGENT_DESKTOP = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'
const USER_AGENT_MOBILE = 'Mozilla/5.0 (Linux; Android 10; Pixel 6a) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36'

Kelvin's avatar
Kelvin committed
const URL_BASE = "https://soundcloud.com";

Koen's avatar
Koen committed
let CLIENT_ID = 'iZIs9mchVcX5lhVRyQGGAYlNPVldzAoX' // correct as of June 2023, enable changes this to get the latest
const URL_ADDITIVE = `&app_version=${SOUNDCLOUD_APP_VERSION}&app_locale=${APP_LOCALE}`

var config = {}

//* Source
source.enable = function (conf) {
    config = conf ?? {}
    CLIENT_ID = getClientId()
    return CLIENT_ID
}
source.getHome = function () {
    return new QueryPager({ page: 1, page_size: 20 })
}
source.searchSuggestions = function (query) {
    const url = `${API_URL}search/queries?q=${query}&client_id=${CLIENT_ID}&limit=10&offset=0&linked_partitioning=1${URL_ADDITIVE}`

    const resp = callUrl(url)

    /** @type {import("./types").SearchAutofillResponse} */
    const json = JSON.parse(resp.body)

    if (!json['collection']) {
        throw new ScriptException('Could not find collection')
    }

    /** @type {{output: string; query: string}[]} */
    const collection = json['collection']

    return collection.map((item) => item['query'])
}
source.getSearchCapabilities = () => {
    return {
        types: [Type.Feed.Mixed], // can also do albums, playlists, channels those do not have types yet
        sorts: [],
        filters: [], // filters depend on type
    }
}
source.search = function (query, type, order, filters) {
    return new SearchPagerVideos({ q: query, page: 1, page_size: 20, get_all: false })
}
source.getSearchChannelContentsCapabilities = function () {
    return {
        types: [Type.Feed.Mixed],
        sorts: [Type.Order.Chronological],
        filters: [],
    }
}
source.searchChannelContent = function (channelUrl, query, type, order, filters) {
    return []
}
source.searchChannels = function (query) {
    return new SearchPagerChannels({ q: query, page: 1, page_size: 20 })
}
source.isChannelUrl = function (url) {
    // see if it matches https://soundcloud.com/nfrealmusic
    return /soundcloud\.com\/[a-zA-Z0-9-_]+\/?/.test(url)
}
source.getChannel = function (url) {
    const resp = callUrl(url)

    const html = resp.body

    const matched = html.match(/window\.__sc_hydration = (.+);/)
    if (!matched) {
        throw new ScriptException('Could not find channel info')
    }

    /** @type {import("./types").SCHydration[]} */
    const json = JSON.parse(matched[1])

    for (let object of json) {
        if (object.hydratable === 'user') {
            return soundcloudUserToPlatformChannel(object.data)
        }
    }

    throw new ScriptException('Could not find channel info')
}
source.getChannelContents = function (url) {
    return new ChannelVideoPager({ url: url, page_size: 20, offset_date: 0 })
}
Kelvin's avatar
Kelvin committed


source.getChannelTemplateByClaimMap = () => {
    return {
        //SoundCloud
        17: {
            0: URL_BASE + "/{{CLAIMVALUE}}"
Koen's avatar
Koen committed
            //Unused! 1: https://api.soundcloud.com/users/{{CLAIMVALUE}}
Koen's avatar
Koen committed
source.isContentDetailsUrl = function (url) {
    // https://soundcloud.com/toosii2x/toosii-favorite-song
    return /soundcloud\.com\/[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+/.test(url)
}
source.getContentDetails = function (url) {
    const resp = callUrl(url)

    const html = resp.body

    const matched = html.match(/window\.__sc_hydration = (.+);/)
    if (!matched) {
        if(IS_TESTING)
            console.log(html);
        throw new ScriptException('Could not find video info')
    }

    /** @type {SCHydration[]} */
    const json = JSON.parse(matched[1])

    /** @type {import("./types").SoundcloudTrack} */
    let data
    /** @type {import("./types").SoundcloudTrack} */
    let sct

    for (let object of json) {
        if (object.hydratable === 'sound') {
            data = object.data
            sct = soundcloudTrackToPlatformVideo(data)
            break
        }
    }

    // for performance reasons, select just the mpeg transcoding if it exists; otherwise, select the first transcoding
    if (data.media.transcodings?.length === 0) throw new ScriptException('Could not find transcodings')
    const transcoding = data.media.transcodings.find((transcoding) => (transcoding.format.mime_type = 'audio/mpeg')) ?? data.media.transcodings[0]

    const authorization = data.track_authorization
    const generated_url = transcoding.url + `?client_id=${CLIENT_ID}&track_authorization=${authorization}`

    const hls_resp = callUrl(generated_url)
    const hls_url = JSON.parse(hls_resp.body).url

    const sources = [
        new HLSSource({
            name: `${transcoding.format.mime_type}`,
            duration: transcoding.duration,
            url: hls_url,
            language: "Unknown"
        }),
    ]

    sct.video = new UnMuxVideoSourceDescriptor([], sources)
    sct.description = data.description
    sct.rating = new RatingLikes(data.likes_count)

    return new PlatformVideoDetails(sct)
}
source.getComments = function (url) {
    return new ExtendableCommentPager({ url: url, page: 1, page_size: 20 })
}
// not in Soundcloud
source.getSubComments = function (comment) {
    return new CommentPager([], false, {})
}
source.getUserSubscriptions = function () {
    const following_resp = callUrl('https://soundcloud.com/you/following', true)
    const html = following_resp.body
    if(IS_TESTING)
        console.log(html)
    const matched = html.match(/window\.__sc_hydration = (.+);/)
    if (!matched) throw new ScriptException('Could not find user info')
    /** @type {SCHydration[]} */
    const following_json = JSON.parse(matched[1])
    let id
    for (let object of following_json) {
        if (object.hydratable === 'meUser') {
            id = object.data.id
            break
        }
    }
    if (!id) throw new ScriptException('Could not find user info')

    const resp = callUrl(`${API_URL}users/${id}/followings?client_id=${CLIENT_ID}&limit=12&offset=0&linked_partitioning=1${URL_ADDITIVE}}`, true)

    const json = JSON.parse(resp.body)

    /** @type {import("./types.d.ts").SoundcloudUser[]} */
    const users = json.collection

    return users.map((user) => user.permalink_url)
}
source.getUserPlaylists = function () {
    const url = `${API_URL}me/library/all?client_id=${CLIENT_ID}&limit=10&offset=0&linked_partioning=1${URL_ADDITIVE}`

    const resp = callUrl(url, true)

    if(IS_TESTING) {
        console.log(url)
        console.log(resp.body)
    }
    const json = JSON.parse(resp.body)

    if(IS_TESTING)
        console.log(json)

    /** @type {import("./types.d.ts").PlaylistWrapper[]} */
    const playlists = json.collection

    return playlists.map((playlist) => {
        if ('playlist' in playlist) {
            return playlist.playlist.permalink_url
        } else if ('system_playlist' in playlist) {
            return playlist.system_playlist.permalink_url
        }
    })
}
source.isPlaylistUrl = function (url) {
    return /soundcloud\.com\/[a-zA-Z0-9-_]+\/sets\/[a-zA-Z0-9-_]+/.test(url)
}
source.getPlaylist = function (url) {
    const resp = callUrl(url, true)

    const html = resp.body

    const matched = html.match(/window\.__sc_hydration = (.+);/)

    if (!matched) {
        throw new ScriptException('Could not find playlist info')
    }

    /** @type {SCHydration[]} */
    const json = JSON.parse(matched[1])

    /** @type {number[]} */
    let ids = []

    for (let object of json) {
        if (object.hydratable === 'systemPlaylist') {
            ids = object.data.tracks.map((track) => track.id)
            break
        } else if (object.hydratable === 'playlist') {
            ids = object.data.tracks.map((track) => track.id)
            break
        }
    }
    /** @type {import("./types.d.ts").SoundcloudTrack[]} */
    let tracks = []

    // split ids into chunks of 50
    for (let i = 0; i < ids.length; i += 50) {
        const chunk = ids.slice(i, i + 50)

        const generated_url = `${API_URL}tracks?ids=${chunk.join(',')}&client_id=${CLIENT_ID}${URL_ADDITIVE}`
        const chunk_resp = callUrl(generated_url, true)
        const found_tracks = JSON.parse(chunk_resp.body)

        tracks = tracks.concat(found_tracks)
    }

    return tracks.map((track) => track.permalink_url)
}

//* Internals
/**
 * Gets the URL with correct headers
 * @param {string} url
 * @param {boolean} is_authenticated
 * @param {boolean} use_mobile
 * @returns {HTTPResponse}
 */
function callUrl(url, is_authenticated = false, use_mobile = false) {
    
    if(!use_mobile) {
        url = removeMobilePrefix(url);
    }
    
Koen's avatar
Koen committed
    let headers = {
        'User-Agent': use_mobile ? USER_AGENT_MOBILE : USER_AGENT_DESKTOP,
        DNT: '1',
        Connection: 'keep-alive',
        Origin: 'https://soundcloud.com',
        Referer: 'https://soundcloud.com/',
    }

    let accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'

    if (url.includes('api-v2.soundcloud.com')) {
        accept = 'application/json, text/javascript, */*; q=0.01'
        headers['Host'] = 'api-v2.soundcloud.com'
        headers['SEC-FETCH-DEST'] = 'empty'
        headers['SEC-FETCH-MODE'] = 'cors'
        headers['SEC-FETCH-SITE'] = 'same-site'
    } else {
        headers['SEC-FETCH-DEST'] = 'document'
        headers['SEC-FETCH-MODE'] = 'navigate'
        headers['SEC-FETCH-SITE'] = 'none'
    }

    headers['Accept'] = accept

    return http.GET(url, headers, is_authenticated)
}

/**
 * Gets the client_id from the Soundcloud home page
 * @returns {string} returns the client_id
 */
function getClientId() {
    // request soundcloud.com to find the url of the js file that contains 50-_____.js
    const resp = callUrl('https://soundcloud.com/discover', false, true)
    const html = resp.body

    // find "clientId":"iZIs9mchVcX5lhVRyQGGAYlNPVldzAoX"
    const matched = html.match(/"clientId":"([a-zA-Z0-9-_]+)"/)

    if (!matched) {
        throw new ScriptException('Could not find client_id')
    }

    const clientId = matched[1]

    return clientId
}

/**
 * Gets the Soundcloud homepage content
 * @param {import("./types").HomeContext} context the search context
 * @returns {PlatformVideo[]} returns the homepage content
 */
function getHomepageContent(context) {
    const limit = context.page_size
    const offset = (context.page - 1) * limit
    const url = `${API_URL}featured_tracks/top/all-music?client_id=${CLIENT_ID}&limit=${limit}&offset=${offset}&linked_partitioning=1${URL_ADDITIVE}`

    const resp = callUrl(url)

    /** @type {import("./types").HomepageResponse} */
    const json = JSON.parse(resp.body)

    /** @type {import("./types").SoundcloudTrack[]} */
    const tracks = json['collection']

    return tracks.map((track) => {
        return soundcloudTrackToPlatformVideo(track)
    })
}

//* Pagers
class QueryPager extends VideoPager {
    /**
     * @param {import("./types.d.ts").HomeContext} context the query params
     */
    constructor(context) {
        const results = getHomepageContent(context)
        super(results, results.length >= context.page_size, context)
    }

    nextPage() {
        this.context.page = this.context.page + 1
        this.results = getHomepageContent(this.context)
        return this
    }
}
class SearchPagerVideos extends VideoPager {
    /**
     * @param {import("./types").SearchContext} context the query params
     */
    constructor(context) {
        if (context.get_all) {
            // https://api-v2.soundcloud.com/search?q=search%20and%20destroy%20drake&client_id=VDJ3iu7ZYtUMibDTM2XcUbRijDa3L6ug&limit=20&offset=0&linked_partitioning=1&app_version=1683798046&app_locale=en
            const limit = context.page_size
            const offset = (context.page - 1) * limit

            const url = `${API_URL}search?q=${encodeURIComponent(
                context.q
            )}&client_id=${CLIENT_ID}&limit=${limit}&offset=${offset}&linked_partitioning=1${URL_ADDITIVE}`

            const resp = callUrl(url)

            /** @type {import("./types").AnySearchResponse} */
            const json = JSON.parse(resp.body)

            if (json['collection'] === undefined) {
                if(IS_TESTING)
                    console.log('Soundcloud search response: ' + resp.body)
                throw new ScriptException('Could not find collection')
            }

            /** @type {(PlatformVideo | PlatformChannel | PlatformPlaylist)[]} */
            const results = []

            for (const result of json['collection']) {
                if (result['kind'] === 'track') {
                    results.push(soundcloudTrackToPlatformVideo(result))
                } else if (result['kind'] === 'user') {
                    continue
                    results.push(soundcloudUserToPlatformChannel(result))
                } else if (result['kind'] === 'playlist' || result['kind'] === 'album') {
                    // results.push(soundcloudPlaylistToPlatformPlaylist(result))
                } else {
                    if(IS_TESTING)
                        console.log('Soundcloud search result: ' + JSON.stringify(result))
                    throw new ScriptException('Unknown kind: ' + result['kind'])
                }
            }

            super(results, results.length >= context.page_size, context)
        } else {
            const limit = context.page_size
            const offset = (context.page - 1) * limit
            const url = `${API_URL}search/tracks?limit=${limit}&offset=${offset}&q=${context.q}&client_id=${CLIENT_ID}${URL_ADDITIVE}`

            const resp = callUrl(url)

            /** @type {SearchResponse} */
            const json = JSON.parse(resp.body)

            /** @type {SoundcloudTrack[]} */
            const tracks = json['collection']

            const results = tracks.map((track) => soundcloudTrackToPlatformVideo(track))

            super(results, results.length >= context.page_size, context)
        }
    }

    nextPage() {
        this.context.page = this.context.page + 1
        return new SearchPagerVideos(this.context)
    }
}
class ChannelVideoPager extends VideoPager {
    /**
     * @param {import("./types.d.ts").ChannelVideoPagerContext} context
     */
    constructor(context) {
        if (!context.id) {
            const resp = callUrl(context.url)
            const matched = resp.body.match(/window\.__sc_hydration = (.+);/)
            if (!matched) {
                throw new ScriptException('Could not find channel info')
            }

            /** @type {import("./types").SCHydration[]} */
            const json = JSON.parse(matched[1])

            for (let object of json) {
                if (object.hydratable === 'user') {
                    /** @type {import("./types").SoundcloudUser} */
                    const data = object.data

                    context.id = data.id
                    break
                }
            }
        }

        const url = `${API_URL}users/${context.id}/tracks?representation=&client_id=${CLIENT_ID}&limit=${context.page_size}&offset=${context.offset_date}&linked_partitioning=1${URL_ADDITIVE}`

        if(IS_TESTING)
            console.log('Soundcloud channel url: ' + url)

        const resp = callUrl(url)
        const parsed = JSON.parse(resp.body)

        /** @type {import("./types").SoundcloudTrack[]} */
        const tracks = parsed['collection']

        const videos = tracks.map((track) => soundcloudTrackToPlatformVideo(track))

        context['offset_date'] = tracks[tracks.length - 1]?.created_at

        super(videos, tracks.length > 0, context)
    }

    nextPage() {
        this.context.page = this.context.page + 1
        return new ChannelVideoPager(this.context)
    }
}
class SearchPagerChannels extends ChannelPager {
    /**
     * @param {import("./types").SearchContext} context the query params
     */
    constructor(context) {
        const limit = context.page_size
        const offset = (context.page - 1) * limit
        const url = `${API_URL}search/users?q=${context.q}&client_id=${CLIENT_ID}&limit=${limit}&offset=${offset}&linked_partitioning=1${URL_ADDITIVE}`

        const resp = callUrl(url)

        /** @type {SearchResponse} */
        const json = JSON.parse(resp.body)

        /** @type {SoundcloudUser[]} */
        const users = json['collection']

        const results = users.map((user) => soundcloudUserToPlatformChannel(user))

        super(results, results.length >= context.page_size, context)
    }

    nextPage() {
        this.context.page = this.context.page + 1
        return new SearchPagerChannels(this.context)
    }
}
class ExtendableCommentPager extends CommentPager {
    /**
     * @param {import("./types.d.ts").HomeContext & {url: string; id: number|null}} context
     */
    constructor(context) {
        if (!context.id) {
            const resp = callUrl(context.url)
            const html = resp.body
            const matched = html.match(/window\.__sc_hydration = (.+);/)
            if (!matched) {
                throw new ScriptException('Could not find comment info')
            }

            /** @type {import("./types").SCHydration[]} */
            const json = JSON.parse(matched[1])

            for (let object of json) {
                if (object.hydratable === 'sound') {
                    /** @type {import("./types").SoundcloudTrack} */
                    const data = object.data

                    context.id = data.id
                    break
                }
            }
        }

        // https://api-v2.soundcloud.com/tracks/1506477625/comments?sort=newest&threaded=1&client_id=TihN0nuDfhghD9GVPbTtrSEa558lYo4V&limit=20&offset=0&linked_partitioning=1&app_version=1684153290&app_locale=en
        const limit = context.page_size
        const offset = (context.page - 1) * limit

        const url = `${API_URL}tracks/${context.id}/comments?sort=newest&threaded=1&client_id=${CLIENT_ID}&limit=${limit}&offset=${offset}&linked_partitioning=1${URL_ADDITIVE}`

        const resp = callUrl(url)

        /** @type {import("./types").CommentResponse} */
        const json = JSON.parse(resp.body)

        const comments = json['collection'].map((comment) => {
            return new Comment({
                contextUrl: context.url,
                author: new PlatformAuthorLink(
                    new PlatformID(PLATFORM, comment.user.id.toString(), config.id, PLATFORM_CLAIMTYPE),
Koen's avatar
Koen committed
                    comment.user.username,
                    comment.user.permalink_url,
                    comment.user.avatar_url
                ),
                message: comment.body,
                rating: new RatingLikes(0),
                date: parseInt(new Date(comment.created_at).getTime() / 1000),
                replyCount: 0,
                context: null,
            })
        })

        super(comments, json['next_href'] !== null, context)
    }

    nextPage() {
        this.context.page = this.context.page + 1
        return new ExtendableCommentPager(this.context)
    }
}

//* CONVERTERS
/**
 * Convert a Soundcloud person to a PlatformChannel
 * @param { import("./types").SoundcloudUser } scu
 * @returns { PlatformChannel }
 */
function soundcloudUserToPlatformChannel(scu) {
    return new PlatformChannel({
        id: new PlatformID(PLATFORM, scu.id.toString(), config.id, PLATFORM_CLAIMTYPE),
Koen's avatar
Koen committed
        name: scu.username,
        thumbnail: scu.avatar_url,
        banner: scu?.visuals?.visuals.length > 0 ? scu.visuals.visuals[0].visual_url : '',
        subscribers: scu.followers_count,
        description: scu.description,
        url: scu.permalink_url,
        links: scu.visuals ? scu.visuals.visuals.map((v) => v.link) : [],
    })
}

/**
 * Convert a Soundcloud Track to a PlatformVideo
 * @param { import("./types").SoundcloudTrack } sct
 * @returns { PlatformVideo }
 */
function soundcloudTrackToPlatformVideo(sct) {
    return new PlatformVideo({
        id: new PlatformID(PLATFORM, sct.id.toString(), config.id),
        name: sct.title,
        thumbnails: new Thumbnails([new Thumbnail(sct.artwork_url !== null ? sct.artwork_url.replace('large', 't500x500') : sct.artwork_url, 0)]),
        author: new PlatformAuthorLink(
            new PlatformID(PLATFORM, sct.user_id.toString(), config.id, PLATFORM_CLAIMTYPE),
Koen's avatar
Koen committed
            sct.user.username,
            sct.user.permalink_url,
            sct.user.avatar_url
        ),
        uploadDate: parseInt(new Date(sct.created_at).getTime() / 1000),
        duration: parseInt(sct.duration / 1000),
        viewCount: sct.playback_count,
        url: sct.permalink_url,
        isLive: false,
    })
}

/**
 * Replace the "m." prefix in a SoundCloud URL with an empty string.
 * 
 * @param {string} url - The SoundCloud URL to modify.
 * @returns {string} - The modified URL without the "m." prefix.
 */
function removeMobilePrefix(url) {
    return url.trim().replace("https://m.", "https://");
}

Koen's avatar
Koen committed
console.log('LOADED')