Shadowfilebot/nodelink
0
1import { PassThrough } from 'node:stream'2 3import config from '../../config.js'4import { debugLog, encodeTrack, http1makeRequest, loadHLS } from '../utils.js'5import searchWithDefault from './default.js'6import sources from '../sources.js'7 8const sourceInfo = {9 clientId: null10}11 12async function init() {13 if (config.search.sources.soundcloud.clientId !== 'AUTOMATIC') {14 sourceInfo.clientId = config.search.sources.soundcloud.clientId15 16 return;17 }18 19 debugLog('soundcloud', 5, { type: 1, message: 'clientId not provided. Fetching clientId...' })20 21 const { body: mainpage } = await http1makeRequest('https://soundcloud.com', {22 method: 'GET'23 }).catch(() => {24 debugLog('soundcloud', 5, { type: 2, message: 'Failed to fetch clientId.' })25 })26 27 const assetId = mainpage.match(/https:\/\/a-v2.sndcdn.com\/assets\/([a-zA-Z0-9-]+).js/gs)[5]28 29 const { body: data } = await http1makeRequest(assetId, {30 method: 'GET'31 }).catch(() => {32 debugLog('soundcloud', 5, { type: 2, message: 'Failed to fetch clientId.' })33 })34 35 const clientId = data.match(/client_id=([a-zA-Z0-9]{32})/)[1]36 37 if (!clientId) {38 debugLog('soundcloud', 5, { type: 2, message: 'Failed to fetch clientId.' })39 40 return;41 }42 43 sourceInfo.clientId = clientId44 45 debugLog('soundcloud', 5, { type: 1, message: 'Successfully fetched clientId.' })46}47 48async function loadFrom(url) {49 let req = await http1makeRequest(`https://api-v2.soundcloud.com/resolve?url=${encodeURI(url)}&client_id=${sourceInfo.clientId}`, { method: 'GET' })50 51 if (req.error || req.statusCode !== 200) {52 const errorMessage = req.error ? req.error.message : `SoundCloud returned invalid status code: ${req.statusCode}`53 54 debugLog('loadtracks', 4, { type: 2, loadType: 'unknown', sourceName: 'Soundcloud', query: url, message: errorMessage })55 56 return {57 loadType: 'error',58 data: {59 message: errorMessage,60 severity: 'fault',61 cause: 'Unknown'62 }63 }64 }65 66 const body = req.body67 68 if (typeof body !== 'object') {69 debugLog('loadtracks', 4, { type: 3, loadType: 'unknown', sourceName: 'Soundcloud', query: url, message: 'Invalid response from SoundCloud.' })70 71 return {72 loadType: 'error',73 data: {74 message: 'Invalid response from SoundCloud.',75 severity: 'common',76 cause: 'Unknown'77 }78 }79 }80 81 debugLog('loadtracks', 4, { type: 1, loadType: body.kind || 'unknown', sourceName: 'SoundCloud', query: url })82 83 if (Object.keys(body).length === 0) {84 debugLog('loadtracks', 4, { type: 3, loadType: body.kind || 'unknown', sourceName: 'Soundcloud', query: url, message: 'No matches found.' })85 86 return {87 loadType: 'empty',88 data: {}89 }90 }91 92 switch (body.kind) {93 case 'track': {94 const track = {95 identifier: body.id.toString(),96 isSeekable: true,97 author: body.user.username,98 length: body.duration,99 isStream: false,100 position: 0,101 title: body.title,102 uri: body.permalink_url,103 artworkUrl: body.artwork_url,104 isrc: body.publisher_metadata ? body.publisher_metadata.isrc : null,105 sourceName: 'soundcloud'106 }107 108 debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'SoundCloud', track, query: url })109 110 return {111 loadType: 'track',112 data: {113 encoded: encodeTrack(track),114 info: track,115 playlistInfo: {}116 }117 }118 }119 case 'playlist': {120 const tracks = []121 const notLoaded = []122 123 if (body.tracks.length > config.options.maxAlbumPlaylistLength)124 data.tracks = body.tracks.slice(0, config.options.maxAlbumPlaylistLength)125 126 body.tracks.forEach((item) => {127 if (!item.title) {128 notLoaded.push(item.id.toString())129 130 return;131 }132 133 const track = {134 identifier: item.id.toString(),135 isSeekable: true,136 author: item.user.username,137 length: item.duration,138 isStream: false,139 position: 0,140 title: item.title,141 uri: item.permalink_url,142 artworkUrl: item.artwork_url,143 isrc: item.publisher_metadata?.isrc,144 sourceName: 'soundcloud'145 }146 147 tracks.push({148 encoded: encodeTrack(track),149 info: track,150 playlistInfo: {}151 })152 })153 154 if (notLoaded.length) {155 let stop = false156 157 while ((notLoaded.length && !stop) && (tracks.length > config.options.maxAlbumPlaylistLength)) {158 const notLoadedLimited = notLoaded.slice(0, 50)159 data = await http1makeRequest(`https://api-v2.soundcloud.com/tracks?ids=${notLoadedLimited.join('%2C')}&client_id=${sourceInfo.clientId}`, { method: 'GET' })160 data = data.body161 162 data.forEach((item) => {163 const track = {164 identifier: item.id.toString(),165 isSeekable: true,166 author: item.user.username,167 length: item.duration,168 isStream: false,169 position: 0,170 title: item.title,171 uri: item.permalink_url,172 artworkUrl: item.artwork_url,173 isrc: item.publisher_metadata ? item.publisher_metadata.isrc : null,174 sourceName: 'soundcloud'175 }176 177 tracks.push({178 encoded: encodeTrack(track),179 info: track,180 playlistInfo: {}181 })182 })183 184 notLoaded.splice(0, 50)185 186 if (notLoaded.length === 0)187 stop = true188 }189 }190 191 debugLog('loadtracks', 4, { type: 2, loadType: 'playlist', sourceName: 'SoundCloud', playlistName: data.title })192 193 return {194 loadType: 'playlist',195 data: {196 info: {197 name: data.title,198 selectedTrack: 0,199 },200 pluginInfo: {},201 tracks,202 }203 }204 }205 case 'user': {206 debugLog('loadtracks', 4, { type: 2, loadType: 'artist', sourceName: 'SoundCloud', playlistName: data.full_name })207 208 return {209 loadType: 'empty',210 data: {}211 }212 }213 }214}215 216async function search(query, shouldLog) {217 if (shouldLog) debugLog('search', 4, { type: 1, sourceName: 'SoundCloud', query })218 219 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' })220 const body = req.body221 222 if (req.error || req.statusCode !== 200) {223 const errorMessage = req.error ? req.error.message : `SoundCloud returned invalid status code: ${req.statusCode}`224 225 debugLog('search', 4, { type: 2, sourceName: 'SoundCloud', query, message: errorMessage })226 227 return {228 exception: {229 message: errorMessage,230 severity: 'fault',231 cause: 'Unknown'232 }233 }234 }235 236 if (body.total_results === 0) {237 debugLog('search', 4, { type: 2, sourceName: 'SoundCloud', query, message: 'No matches found.' })238 239 return {240 loadType: 'empty',241 data: {}242 }243 }244 245 const tracks = []246 247 if (body.collection.length > config.options.maxSearchResults)248 body.collection = body.collection.filter((item, i) => i < config.options.maxSearchResults || item.kind === 'track')249 250 body.collection.forEach((item) => {251 if (item.kind !== 'track') return;252 253 const track = {254 identifier: item.id.toString(),255 isSeekable: true,256 author: item.user.username,257 length: item.duration,258 isStream: false,259 position: 0,260 title: item.title,261 uri: item.uri,262 artworkUrl: item.artwork_url,263 isrc: null,264 sourceName: 'soundcloud'265 }266 267 tracks.push({268 encoded: encodeTrack(track),269 info: track,270 pluginInfo: {}271 })272 })273 274 if (shouldLog)275 debugLog('search', 4, { type: 2, sourceName: 'SoundCloud', tracksLen: tracks.length, query })276 277 return {278 loadType: 'search',279 data: tracks280 }281}282 283async function retrieveStream(identifier, title) {284 const req = await http1makeRequest(`https://api-v2.soundcloud.com/resolve?url=https://api.soundcloud.com/tracks/${identifier}&client_id=${sourceInfo.clientId}`, { method: 'GET' })285 const body = req.body286 287 if (req.error || req.statusCode !== 200) {288 const errorMessage = req.error ? req.error.message : `SoundCloud returned invalid status code: ${req.statusCode}`289 290 debugLog('retrieveStream', 4, { type: 2, sourceName: 'SoundCloud', query: title, message: errorMessage })291 292 return {293 exception: {294 message: errorMessage,295 severity: 'fault',296 cause: 'Unknown'297 }298 }299 }300 301 if (body.errors) {302 debugLog('retrieveStream', 4, { type: 2, sourceName: 'SoundCloud', query: title, message: body.errors[0].error_message })303 304 return {305 exception: {306 message: body.errors[0].error_message,307 severity: 'fault',308 cause: 'Unknown'309 }310 }311 }312 313 const oggOpus = body.media.transcodings.find((transcoding) => transcoding.format.mime_type === 'audio/ogg; codecs="opus"')314 const transcoding = oggOpus || body.media.transcodings[0]315 316 if (transcoding.snipped && config.search.sources.soundcloud.fallbackIfSnipped) {317 debugLog('retrieveStream', 4, { type: 2, sourceName: 'SoundCloud', query: title, message: `Track is snipped, falling back to: ${config.search.fallbackSearchSource}.` })318 319 const search = await searchWithDefault(title, true)320 321 if (search.loadType === 'search') {322 const urlInfo = await sources.getTrackURL(search.data[0].info)323 324 return {325 url: urlInfo.url,326 protocol: urlInfo.protocol,327 format: urlInfo.format,328 additionalData: true329 }330 }331 }332 333 return {334 url: `${transcoding.url}?client_id=${sourceInfo.clientId}`,335 protocol: transcoding.format.protocol,336 format: oggOpus ? 'ogg/opus' : 'arbitrary'337 }338}339 340async function loadHLSStream(url) {341 const streamHlsRedirect = await http1makeRequest(url, { method: 'GET' })342 343 const stream = new PassThrough()344 await loadHLS(streamHlsRedirect.body.url, stream)345 346 return stream347}348 349async function loadFilters(url, protocol) {350 if (protocol === 'hls') {351 const streamHlsRedirect = await http1makeRequest(url, { method: 'GET' })352 353 return streamHlsRedirect.body.url354 } else {355 return url356 }357}358 359export default {360 init,361 loadFrom,362 search,363 retrieveStream,364 loadHLSStream,365 loadFilters366}367 