Spaces:
Runtime error
Runtime error
| 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 | |
| } | |