nodelink / src /sources /soundcloud.js
flameface's picture
Upload 25 files
b58c6cb verified
import { PassThrough } from 'node:stream'
import config from '../../config.js'
import { debugLog, encodeTrack, http1makeRequest, loadHLS } from '../utils.js'
import searchWithDefault from './default.js'
import sources from '../sources.js'
const sourceInfo = {
clientId: null
}
async function init() {
if (config.search.sources.soundcloud.clientId !== 'AUTOMATIC') {
sourceInfo.clientId = config.search.sources.soundcloud.clientId
return;
}
debugLog('soundcloud', 5, { type: 1, message: 'clientId not provided. Fetching clientId...' })
const { body: mainpage } = await http1makeRequest('https://soundcloud.com', {
method: 'GET'
}).catch(() => {
debugLog('soundcloud', 5, { type: 2, message: 'Failed to fetch clientId.' })
})
const assetId = mainpage.match(/https:\/\/a-v2.sndcdn.com\/assets\/([a-zA-Z0-9-]+).js/gs)[5]
const { body: data } = await http1makeRequest(assetId, {
method: 'GET'
}).catch(() => {
debugLog('soundcloud', 5, { type: 2, message: 'Failed to fetch clientId.' })
})
const clientId = data.match(/client_id=([a-zA-Z0-9]{32})/)[1]
if (!clientId) {
debugLog('soundcloud', 5, { type: 2, message: 'Failed to fetch clientId.' })
return;
}
sourceInfo.clientId = clientId
debugLog('soundcloud', 5, { type: 1, message: 'Successfully fetched clientId.' })
}
async function loadFrom(url) {
let req = await http1makeRequest(`https://api-v2.soundcloud.com/resolve?url=${encodeURI(url)}&client_id=${sourceInfo.clientId}`, { method: 'GET' })
if (req.error || req.statusCode !== 200) {
const errorMessage = req.error ? req.error.message : `SoundCloud returned invalid status code: ${req.statusCode}`
debugLog('loadtracks', 4, { type: 2, loadType: 'unknown', sourceName: 'Soundcloud', query: url, message: errorMessage })
return {
loadType: 'error',
data: {
message: errorMessage,
severity: 'fault',
cause: 'Unknown'
}
}
}
const body = req.body
if (typeof body !== 'object') {
debugLog('loadtracks', 4, { type: 3, loadType: 'unknown', sourceName: 'Soundcloud', query: url, message: 'Invalid response from SoundCloud.' })
return {
loadType: 'error',
data: {
message: 'Invalid response from SoundCloud.',
severity: 'common',
cause: 'Unknown'
}
}
}
debugLog('loadtracks', 4, { type: 1, loadType: body.kind || 'unknown', sourceName: 'SoundCloud', query: url })
if (Object.keys(body).length === 0) {
debugLog('loadtracks', 4, { type: 3, loadType: body.kind || 'unknown', sourceName: 'Soundcloud', query: url, message: 'No matches found.' })
return {
loadType: 'empty',
data: {}
}
}
switch (body.kind) {
case 'track': {
const track = {
identifier: body.id.toString(),
isSeekable: true,
author: body.user.username,
length: body.duration,
isStream: false,
position: 0,
title: body.title,
uri: body.permalink_url,
artworkUrl: body.artwork_url,
isrc: body.publisher_metadata ? body.publisher_metadata.isrc : null,
sourceName: 'soundcloud'
}
debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'SoundCloud', track, query: url })
return {
loadType: 'track',
data: {
encoded: encodeTrack(track),
info: track,
playlistInfo: {}
}
}
}
case 'playlist': {
const tracks = []
const notLoaded = []
if (body.tracks.length > config.options.maxAlbumPlaylistLength)
data.tracks = body.tracks.slice(0, config.options.maxAlbumPlaylistLength)
body.tracks.forEach((item) => {
if (!item.title) {
notLoaded.push(item.id.toString())
return;
}
const track = {
identifier: item.id.toString(),
isSeekable: true,
author: item.user.username,
length: item.duration,
isStream: false,
position: 0,
title: item.title,
uri: item.permalink_url,
artworkUrl: item.artwork_url,
isrc: item.publisher_metadata?.isrc,
sourceName: 'soundcloud'
}
tracks.push({
encoded: encodeTrack(track),
info: track,
playlistInfo: {}
})
})
if (notLoaded.length) {
let stop = false
while ((notLoaded.length && !stop) && (tracks.length > config.options.maxAlbumPlaylistLength)) {
const notLoadedLimited = notLoaded.slice(0, 50)
data = await http1makeRequest(`https://api-v2.soundcloud.com/tracks?ids=${notLoadedLimited.join('%2C')}&client_id=${sourceInfo.clientId}`, { method: 'GET' })
data = data.body
data.forEach((item) => {
const track = {
identifier: item.id.toString(),
isSeekable: true,
author: item.user.username,
length: item.duration,
isStream: false,
position: 0,
title: item.title,
uri: item.permalink_url,
artworkUrl: item.artwork_url,
isrc: item.publisher_metadata ? item.publisher_metadata.isrc : null,
sourceName: 'soundcloud'
}
tracks.push({
encoded: encodeTrack(track),
info: track,
playlistInfo: {}
})
})
notLoaded.splice(0, 50)
if (notLoaded.length === 0)
stop = true
}
}
debugLog('loadtracks', 4, { type: 2, loadType: 'playlist', sourceName: 'SoundCloud', playlistName: data.title })
return {
loadType: 'playlist',
data: {
info: {
name: data.title,
selectedTrack: 0,
},
pluginInfo: {},
tracks,
}
}
}
case 'user': {
debugLog('loadtracks', 4, { type: 2, loadType: 'artist', sourceName: 'SoundCloud', playlistName: data.full_name })
return {
loadType: 'empty',
data: {}
}
}
}
}
async function search(query, shouldLog) {
if (shouldLog) debugLog('search', 4, { type: 1, sourceName: 'SoundCloud', query })
const req = await http1makeRequest(`https://api-v2.soundcloud.com/search?q=${encodeURI(query)}&variant_ids=&facet=model&user_id=992000-167630-994991-450103&client_id=${sourceInfo.clientId}&limit=${config.options.maxResultsLength}&offset=0&linked_partitioning=1&app_version=1679652891&app_locale=en`, { method: 'GET' })
const body = req.body
if (req.error || req.statusCode !== 200) {
const errorMessage = req.error ? req.error.message : `SoundCloud returned invalid status code: ${req.statusCode}`
debugLog('search', 4, { type: 2, sourceName: 'SoundCloud', query, message: errorMessage })
return {
exception: {
message: errorMessage,
severity: 'fault',
cause: 'Unknown'
}
}
}
if (body.total_results === 0) {
debugLog('search', 4, { type: 2, sourceName: 'SoundCloud', query, message: 'No matches found.' })
return {
loadType: 'empty',
data: {}
}
}
const tracks = []
if (body.collection.length > config.options.maxSearchResults)
body.collection = body.collection.filter((item, i) => i < config.options.maxSearchResults || item.kind === 'track')
body.collection.forEach((item) => {
if (item.kind !== 'track') return;
const track = {
identifier: item.id.toString(),
isSeekable: true,
author: item.user.username,
length: item.duration,
isStream: false,
position: 0,
title: item.title,
uri: item.uri,
artworkUrl: item.artwork_url,
isrc: null,
sourceName: 'soundcloud'
}
tracks.push({
encoded: encodeTrack(track),
info: track,
pluginInfo: {}
})
})
if (shouldLog)
debugLog('search', 4, { type: 2, sourceName: 'SoundCloud', tracksLen: tracks.length, query })
return {
loadType: 'search',
data: tracks
}
}
async function retrieveStream(identifier, title) {
const req = await http1makeRequest(`https://api-v2.soundcloud.com/resolve?url=https://api.soundcloud.com/tracks/${identifier}&client_id=${sourceInfo.clientId}`, { method: 'GET' })
const body = req.body
if (req.error || req.statusCode !== 200) {
const errorMessage = req.error ? req.error.message : `SoundCloud returned invalid status code: ${req.statusCode}`
debugLog('retrieveStream', 4, { type: 2, sourceName: 'SoundCloud', query: title, message: errorMessage })
return {
exception: {
message: errorMessage,
severity: 'fault',
cause: 'Unknown'
}
}
}
if (body.errors) {
debugLog('retrieveStream', 4, { type: 2, sourceName: 'SoundCloud', query: title, message: body.errors[0].error_message })
return {
exception: {
message: body.errors[0].error_message,
severity: 'fault',
cause: 'Unknown'
}
}
}
const oggOpus = body.media.transcodings.find((transcoding) => transcoding.format.mime_type === 'audio/ogg; codecs="opus"')
const transcoding = oggOpus || body.media.transcodings[0]
if (transcoding.snipped && config.search.sources.soundcloud.fallbackIfSnipped) {
debugLog('retrieveStream', 4, { type: 2, sourceName: 'SoundCloud', query: title, message: `Track is snipped, falling back to: ${config.search.fallbackSearchSource}.` })
const search = await searchWithDefault(title, true)
if (search.loadType === 'search') {
const urlInfo = await sources.getTrackURL(search.data[0].info)
return {
url: urlInfo.url,
protocol: urlInfo.protocol,
format: urlInfo.format,
additionalData: true
}
}
}
return {
url: `${transcoding.url}?client_id=${sourceInfo.clientId}`,
protocol: transcoding.format.protocol,
format: oggOpus ? 'ogg/opus' : 'arbitrary'
}
}
async function loadHLSStream(url) {
const streamHlsRedirect = await http1makeRequest(url, { method: 'GET' })
const stream = new PassThrough()
await loadHLS(streamHlsRedirect.body.url, stream)
return stream
}
async function loadFilters(url, protocol) {
if (protocol === 'hls') {
const streamHlsRedirect = await http1makeRequest(url, { method: 'GET' })
return streamHlsRedirect.body.url
} else {
return url
}
}
export default {
init,
loadFrom,
search,
retrieveStream,
loadHLSStream,
loadFilters
}