mirror of
https://codeberg.org/lucida/lucida.git
synced 2026-11-13 15:17:25 -05:00
Initial commit
This commit is contained in:
14
src/streamers/soundcloud/constants.ts
Normal file
14
src/streamers/soundcloud/constants.ts
Normal 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'
|
||||
255
src/streamers/soundcloud/main.ts
Normal file
255
src/streamers/soundcloud/main.ts
Normal 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
|
||||
}
|
||||
}
|
||||
111
src/streamers/soundcloud/parse.ts
Normal file
111
src/streamers/soundcloud/parse.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user