import { fetch, HeadersInit } from 'undici' import { DEFAULT_HEADERS } from './constants.js' import { ItemType, Streamer, SearchResults, GetByUrlResponse, GetStreamResponse, StreamerAccount, TrackGetByUrlResponse } from '../../types.js' import { parseAlbum, parseTrack, parseArtist, parseHls, RawSearchResults, RawTrack, RawAlbum, RawArtist, ScClient } from './parse.js' import { Readable } from 'stream' function headers(oauthToken?: string | undefined): HeadersInit { const headers: HeadersInit = DEFAULT_HEADERS if (oauthToken) headers['Authorization'] = 'OAuth ' + oauthToken return headers } interface SoundcloudOptions { oauthToken?: string } interface SoundcloudTranscoding { url: string preset: string duration: number snipped: boolean format: { protocol: string mime_type: string } quality: string } interface SoundCloudSubscriptionData { active_subscription: { state: string subscription_period_started_at: string expires_at: string recurring: boolean trial: boolean is_eligible: boolean } | null } export default class Soundcloud implements Streamer { hostnames = ['soundcloud.com', 'm.soundcloud.com', 'www.soundcloud.com'] testData = { 'https://soundcloud.com/saoirsedream/charlikartlanparty': { type: 'track', title: 'Charli Kart LAN Party' }, 'https://soundcloud.com/saoirsedream/sets/star': { type: 'album', title: 'star★☆' } } as const oauthToken?: string client?: ScClient constructor(options: SoundcloudOptions) { this.oauthToken = options?.oauthToken } async search(query: string, limit = 20): Promise { const client = this.client || (await this.#getClient()) const response = await fetch( this.#formatURL( `https://api-v2.soundcloud.com/search?q=${encodeURIComponent( query )}&offset=0&linked_partitioning=1&app_locale=en&limit=${limit}`, client ), { method: 'get', headers: headers(this.oauthToken) } ) if (!response.ok) { const errMsg = await response.text() try { throw new Error(JSON.parse(errMsg)) } catch (error) { if (errMsg) throw new Error(errMsg) else throw new Error('Soundcloud request failed. Try removing the OAuth token, if added.') } } const resultResponse = await response.json() const items: SearchResults = { query, albums: [], tracks: [], artists: [] } for (const i in resultResponse.collection) { if (resultResponse.collection[i].kind == 'track') items.tracks.push(await parseTrack(resultResponse.collection[i])) else if (resultResponse.collection[i].kind == 'playlist') items.albums.push(await parseAlbum(resultResponse.collection[i])) else if (resultResponse.collection[i].kind == 'user') items.artists.push(await parseArtist(resultResponse.collection[i])) } return items } async #getClient(): Promise { const response = await ( await fetch(`https://soundcloud.com/`, { method: 'get', headers: headers() }) ).text() const client = { version: response.split(`__sc_version="`)[1].split(`"`)[0], anonId: response.split(`[{"hydratable":"anonymousId","data":"`)[1].split(`"`)[0], id: await fetchKey(response) } this.client = client return client } async getTypeFromUrl(url: string): Promise { const { pathname } = new URL(url) if (pathname.split('/').slice(1).length == 1) return 'artist' else { if (pathname.split('/')?.[2] == 'sets') return 'album' else return 'track' } } async getByUrl(url: string): Promise { return await this.#getMetadata(url) } #formatURL(og: string, client: ScClient): string { const parsed = new URL(og) if (client.anonId && !this.oauthToken) parsed.searchParams.append('user_id', client.anonId) if (client.id) parsed.searchParams.append('client_id', client.id) if (client.version) parsed.searchParams.append('app_version', client.version) if (!parsed.searchParams.get('app_locale')) parsed.searchParams.append('app_locale', 'en') return parsed.href } async #getMetadata(url: string): Promise { // loosely based off: https://github.com/wukko/cobalt/blob/92c0e1d7b7df262fcd82ea7f5cf8c58c6d2ad744/src/modules/processing/services/soundcloud.js const type = await this.getTypeFromUrl(url) const client = this.client || (await this.#getClient()) url = url.replace('//m.', '//') // getting the IDs and track authorization const html = await (await fetch(url, { method: 'get', headers: headers() })).text() switch (type) { case 'track': { const trackId = html.split(`"soundcloud://sounds:`)?.[1]?.split(`">`)?.[0] let naked = `https://api-v2.soundcloud.com/tracks/${trackId}` const path = new URL(url).pathname if (path.split('/').length == 4) naked = `${naked}?secret_token=${path.split('/')[3]}` const api = JSON.parse( await ( await fetch(this.#formatURL(naked, client), { method: 'get', headers: headers(this.oauthToken) }) ).text() ) return { type: 'track', getStream: async (hq?: boolean) => { if (!hq) hq = false return await getStream( hq, api.media.transcodings, api.track_authorization, client, this.oauthToken ) }, metadata: await parseTrack(api) } } case 'album': { const data = JSON.parse(html.split(`"hydratable":"playlist","data":`)[1].split(`}];`)[0]) const parsed: GetByUrlResponse = { type: 'album', metadata: await parseAlbum(data), tracks: [] } for (const i in data.tracks) { let track = data.tracks[i] if (!track.title) track = await this.#getRawTrackInfo(track.id, client) const parsedTrack = { type: 'track', id: track.id, title: track.title, url: track.permalink_url, artists: [ { id: data.user.id, name: data.user.username, url: data.user.permalink_url, pictures: [data.user.avatar_url.replace('-large', '-original')] } ], durationMs: track.media?.transcodings?.[0]?.duration, coverArtwork: track.artwork_url?.replace('-large', '-original') } parsed.tracks.push(parsedTrack) } return parsed } case 'artist': { const data = JSON.parse(html.split(`{"hydratable":"user","data":`)[1].split(`}];`)[0]) return { type: 'artist', metadata: await parseArtist(data) } } default: throw `Type "${type}" not supported.` } } async #getRawTrackInfo(id: number | string, client: ScClient) { const api = JSON.parse( await ( await fetch(this.#formatURL(`https://api-v2.soundcloud.com/tracks/${id}`, client), { method: 'get', headers: headers(this.oauthToken) }) ).text() ) return { ...api, id } } async getAccountInfo(): Promise { const client = this.client || (await this.#getClient()) const subscriptionQuery = await ( await fetch( this.#formatURL( `https://api-v2.soundcloud.com/payments/quotations/consumer-subscription`, client ), { method: 'get', headers: headers(this.oauthToken) } ) ).json() return { valid: true, premium: subscriptionQuery?.active_subscription?.state == 'active', explicit: true } } } async function fetchKey(response: string) { // loosely based on https://gitlab.com/sylviiu/soundcloud-key-fetch/-/blob/master/index.js const keys = response.split(`