Initial commit

This commit is contained in:
hazycora
2023-09-16 22:05:49 -04:00
commit fc4c8ceb40
22 changed files with 3476 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
export const DEFAULT_HEADERS = {
Accept: 'application/json, text/javascript, */*',
'Accept-Language': 'en-US',
Connection: 'keep-alive',
DNT: '1',
Origin: 'https://soundcloud.com',
Referer: 'https://soundcloud.com/',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-site',
'Sec-GPC': '1',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/113.0'
}
export const SC_VERSION = '1686222762'

View File

@@ -0,0 +1,255 @@
import fetch from 'node-fetch'
import { DEFAULT_HEADERS, SC_VERSION } from './constants.js'
import { Streamer, SearchResults, GetByUrlResponse, GetStreamResponse } from '../../types.js'
import {
parseAlbum,
parseTrack,
parseArtist,
RawSearchResults,
RawTrack,
RawAlbum,
RawArtist,
ScClient
} from './parse.js'
function headers(token?: string): HeadersInit {
const headers: HeadersInit = DEFAULT_HEADERS
if (token) headers['Authentication'] = 'OAuth' + token
return headers
}
interface SoundcloudTranscoding {
url: string
preset: string
duration: number
snipped: boolean
format: {
protocol: string
mime_type: string
}
quality: string
}
export default class Soundcloud implements Streamer {
hostnames = ['soundcloud.com', 'm.soundcloud.com', 'www.soundcloud.com']
oauthToken?: string
token?: string
async search(query: string, limit = 20): Promise<SearchResults> {
const client = await this.#getClient()
if (this.oauthToken) client.id = this.oauthToken
const response = await fetch(
this.#formatURL(
`https://api-v2.soundcloud.com/search?q=${encodeURIComponent(
query
)}&app_locale=en&limit=${limit}`,
client
),
{ method: 'get', headers: headers(this.token) }
)
if (!response.ok) {
const errMsg = await response.text()
try {
console.error('Soundcloud error response:', JSON.parse(errMsg))
} catch (error) {
console.error('Soundcloud error response:', errMsg)
}
throw new Error(`Searching Soundcloud failed with status code ${response.status}.`)
}
const resultResponse = <RawSearchResults>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(<RawTrack>resultResponse.collection[i]))
else if (resultResponse.collection[i].kind == 'playlist')
items.albums.push(await parseAlbum(<RawAlbum>resultResponse.collection[i]))
else if (resultResponse.collection[i].kind == 'user')
items.artists.push(parseArtist(<RawArtist>resultResponse.collection[i]))
}
return items
}
async #getClient(): Promise<ScClient> {
const response = await (
await fetch(`https://soundcloud.com/`, {
method: 'get',
headers: headers(this.token)
})
).text()
const client = {
version: SC_VERSION,
anonId: response.split(`[{"hydratable":"anonymousId","data":"`)[1].split(`"`)[0],
id: await fetchKey(response)
}
return client
}
getTypeFromUrl(url: string): 'artist' | 'album' | 'track' {
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<GetByUrlResponse> {
return await this.#getMetadata(url)
}
#formatURL(og: string, client: ScClient): string {
let url = og
const parsed = new URL(og)
if (!parsed.searchParams.get('user_id')) url = url + `&user_id=${client.anonId}`
if (!parsed.searchParams.get('client_id')) url = url + `&client_id=${client.id}`
if (!parsed.searchParams.get('app_version')) url = url + `&app_version=${client.version}`
if (!parsed.searchParams.get('app_locale')) url = url + `&app_locale=en`
return url
}
async #getMetadata(url: string): Promise<GetByUrlResponse> {
// loosely based off: https://github.com/wukko/cobalt/blob/92c0e1d7b7df262fcd82ea7f5cf8c58c6d2ad744/src/modules/processing/services/soundcloud.js
const type = this.getTypeFromUrl(url)
const client = await this.#getClient()
if (this.oauthToken) client.id = this.oauthToken
// getting the IDs and track authorization
const html = await (await fetch(url, { method: 'get', headers: headers(this.token) })).text()
switch (type) {
case 'track': {
const trackId = html
.split(`{"hydratable":"sound",`)?.[1]
?.split(`"id":`)?.[1]
?.split(',')?.[0]
const trackAuthorization = html.split(`"track_authorization":"`)[1].split(`"`)[0]
const api = JSON.parse(
await (
await fetch(
this.#formatURL(`https://api-v2.soundcloud.com/tracks?ids=${trackId}`, client),
{ method: 'get', headers: headers(this.token) }
)
).text()
)[0]
return {
type: 'track',
getStream: async () => {
return await getStream(api.media.transcodings, trackAuthorization, client)
},
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: await Promise.all(
data.tracks.map(async (track: RawTrack) => {
if (!track.title) track = await this.#getRawTrackInfo(track.id, client)
track.user = data.user
return parseTrack(track)
})
)
}
for (const i in data.tracks) {
let track = data.tracks[i]
const parsedTrack = {
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: parseArtist(data)
}
}
}
}
async #getRawTrackInfo(id: number | string, client: ScClient) {
const api = JSON.parse(
await (
await fetch(this.#formatURL(`https://api-v2.soundcloud.com/tracks?ids=${id}`, client), {
method: 'get',
headers: headers(this.token)
})
).text()
)[0]
return api
}
}
async function fetchKey(response: string) {
// loosely based on https://gitlab.com/sylviiu/soundcloud-key-fetch/-/blob/master/index.js
const keys = response.split(`<script crossorigin src="`)
let streamKey
for (const i in keys) {
if (typeof streamKey == 'string') continue
const key = keys[i].split(`"`)[0]
if (!key.startsWith('https://a-v2.sndcdn.com/assets/50-')) continue
const script = await (await fetch(key)).text()
if (script.split(`,client_id:"`).length > 1 && !streamKey) {
streamKey = script.split(`,client_id:"`)?.[1]?.split(`"`)?.[0]
} else continue
}
return streamKey
}
async function getStream(
transcodings: Array<SoundcloudTranscoding>,
trackAuth: string,
client: ScClient
): Promise<GetStreamResponse> {
const progressive = transcodings.filter((x) => x.format?.protocol == 'progressive')[0]
const streamUrlResp = await fetch(
`${progressive.url}?client_id=${client.id}&track_authorization=${trackAuth}`
)
const { url } = <{ url: string }>await streamUrlResp.json()
const streamResp = await fetch(url)
return {
mimeType: progressive.format.mime_type,
sizeBytes: parseInt(<string>streamResp.headers.get('Content-Length')),
stream: <NodeJS.ReadableStream>streamResp.body
}
}

View File

@@ -0,0 +1,111 @@
import { Artist, Album, Track } from '../../types.js'
import fetch from 'node-fetch'
import sizeOf from 'image-size'
async function parseCoverArtwork(url: string) {
const resp = await fetch(url)
if (!resp.body) throw new Error('No body on image')
const chunks = []
for await (const chunk of resp.body) {
chunks.push(Buffer.from(chunk))
}
const dimensions = sizeOf(Buffer.concat(chunks))
if (!dimensions.width || !dimensions.height) throw new Error(`Couldn't get dimensions`)
return {
url,
width: dimensions.width,
height: dimensions.height
}
}
export interface RawArtist {
id: number
username: string
full_name: string
avatar_url?: string
permalink_url: string
kind: 'user'
}
export function parseArtist(raw: RawArtist): Artist {
const artist: Artist = {
id: raw.id,
url: raw.permalink_url,
name: raw.username
}
if (raw.avatar_url) {
artist.pictures = [raw.avatar_url, raw.avatar_url?.replace('-large', '-original')]
}
return artist
}
export interface RawAlbum {
title: string
id: number
permalink_url: string
tracks: RawTrack[]
track_count: number
release_date: string
user: RawArtist
kind: 'playlist'
}
export async function parseAlbum(raw: RawAlbum): Promise<Album> {
const album: Album = {
id: raw.id,
title: raw.title,
url: raw.permalink_url,
trackCount: raw.track_count,
releaseDate: new Date(raw.release_date),
artists: [parseArtist(raw.user)]
}
if (raw.tracks?.[0]?.artwork_url) {
album.coverArtwork = [
await parseCoverArtwork(raw.tracks[0].artwork_url.replace('-large', '-original'))
]
}
return album
}
export interface RawTrack {
media?: {
transcodings?: { duration: number }[]
}
kind: 'track'
id: number
title: string
permalink_url: string
artwork_url?: string
full_duration: number
user: RawArtist
}
export async function parseTrack(raw: RawTrack): Promise<Track> {
const track: Track = {
id: raw.id,
title: raw.title,
url: raw.permalink_url,
artists: [parseArtist(raw.user)],
durationMs: raw.media?.transcodings?.[0]?.duration
}
if (raw.artwork_url) {
track.coverArtwork = [await parseCoverArtwork(raw.artwork_url?.replace('-large', '-original'))]
}
return track
}
export interface RawSearchResults {
collection: (RawAlbum | RawTrack | RawArtist)[]
total_results: number
facets: []
next_href: string
query_urn: string
}
export interface ScClient {
anonId: string
version: string
id: string | undefined
}