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 }