import http from 'node:http' import https from 'node:https' import http2 from 'node:http2' import zlib from 'node:zlib' import process from 'node:process' import { Buffer } from 'node:buffer' import { URL } from 'node:url' import { PassThrough } from 'node:stream' import config from '../config.js' import constants from '../constants.js' export function randomLetters(size) { let result = '' const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' let counter = 0 while (counter < size) { result += characters.charAt(Math.floor(Math.random() * characters.length)) counter++ } return result } function _http1Events(request, headers, statusCode) { return new Promise((resolve) => { let data = '' request.setEncoding('utf8') request.on('data', (chunk) => data += chunk) request.on('end', () => { resolve({ statusCode: statusCode, headers: headers, body: (headers && headers['content-type'] && headers['content-type'].startsWith('application/json')) ? JSON.parse(data) : data }) }) }) } export function http1makeRequest(url, options) { return new Promise(async (resolve, reject) => { let compression = null let req = (url.startsWith('https') ? https : http).request(url, { method: options.method, headers: { 'Accept-Encoding': 'br, gzip, deflate', 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0', 'DNT': '1', ...(options.headers || {}), ...(options.body ? { 'Content-Type': 'application/json' } : {}) } }, async (res) => { const statusCode = res.statusCode const headers = res.headers if (headers.location) { resolve(http1makeRequest(headers.location, options)) return res.destroy() } switch (res.headers['content-encoding']) { case 'deflate': { compression = zlib.createInflate() break } case 'br': { compression = zlib.createBrotliDecompress() break } case 'gzip': { compression = zlib.createGunzip() break } } if (compression) { res.pipe(compression) if (options.streamOnly) { return resolve({ statusCode, headers, stream: compression }) } resolve(await _http1Events(compression, headers, statusCode)) } else { if (options.streamOnly) { return resolve({ statusCode, headers, stream: res }) } resolve(await _http1Events(res, headers, statusCode)) } }) if (options.body) { if (options.disableBodyCompression || process.versions.deno) req.end(JSON.stringify(options.body)) else zlib.gzip(JSON.stringify(options.body), (error, data) => { if (error) throw new Error(`\u001b[31mhttp1makeRequest\u001b[37m]: Failed gziping body: ${error}`) req.end(data) }) } else req.end() req.on('error', (error) => { console.error(`[\u001b[31mhttp1makeRequest\u001b[37m]: Failed sending HTTP request to ${url}: \u001b[31m${error}\u001b[37m`) reject(error) }) }) } function _http2Events(request, headers) { return new Promise((resolve) => { let data = '' request.setEncoding('utf8') request.on('data', (chunk) => data += chunk) request.on('end', () => { resolve({ statusCode: headers[':status'], headers: headers, body: (headers && headers['content-type'] && headers['content-type'].startsWith('application/json')) ? JSON.parse(data) : data }) }) }) } export function makeRequest(url, options) { if (process.versions.deno) return http1makeRequest(url, options) return new Promise(async (resolve) => { const parsedUrl = new URL(url) let compression = null const client = http2.connect(parsedUrl.origin) let reqOptions = { ':method': options.method, ':path': parsedUrl.pathname + parsedUrl.search, 'Accept-Encoding': 'br, gzip, deflate', 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0', 'DNT': '1', ...(options.headers || {}) } if (options.body) { if (!options.disableBodyCompression) reqOptions['Content-Encoding'] = 'gzip' reqOptions['Content-Type'] = 'application/json' } let req = client.request(reqOptions) client.on('error', () => { /* Add listener or else will crash */ }) req.on('error', (error) => { console.error(`[\u001b[31mmakeRequest\u001b[37m]: Failed sending HTTP request to ${url}: \u001b[31m${error}\u001b[37m`) resolve({ error }) }) req.on('response', async (headers) => { if (headers.location) { client.close() req.destroy() return resolve(makeRequest(headers.location, options)) } switch (headers['content-encoding']) { case 'deflate': { compression = zlib.createInflate() break } case 'br': { compression = zlib.createBrotliDecompress() break } case 'gzip': { compression = zlib.createGunzip() break } } if (compression) { req.pipe(compression) if (options.streamOnly) { req.on('end', () => client.close()) return resolve({ statusCode: headers[':status'], headers: headers, stream: compression }) } compression.on('error', (error) => { console.error(`[\u001b[31mmakeRequest\u001b[37m]: Failed decompressing HTTP response: \u001b[31m${error}\u001b[37m`) resolve({ error }) }) resolve(await _http2Events(compression, headers)) client.close() } else { if (options.streamOnly) { req.on('end', () => client.close()) return resolve({ statusCode: headers[':status'], headers: headers, stream: req }) } resolve(await _http2Events(req, headers)) client.close() } }) if (options.body) { if (options.disableBodyCompression) req.end(JSON.stringify(options.body)) else zlib.gzip(JSON.stringify(options.body), (error, data) => { if (error) throw new Error(`\u001b[31mmakeRequest\u001b[37m]: Failed gziping body: ${error}`) req.end(data) }) } else req.end() }) } class EncodeClass { constructor() { this.position = 0 this.buffer = Buffer.alloc(512) } changeBytes(bytes) { if (this.position + bytes > this.buffer.length) { const newBuffer = Buffer.alloc(Math.max(this.buffer.length * 2, this.position + bytes)) this.buffer.copy(newBuffer) this.buffer = newBuffer } this.position += bytes return this.position - bytes } write(type, value) { switch (type) { case 'byte': { this.buffer[this.changeBytes(1)] = value break } case 'unsignedShort': { this.buffer.writeUInt16BE(value, this.changeBytes(2)) break } case 'int': { this.buffer.writeInt32BE(value, this.changeBytes(4)) break } case 'long': { const msb = value / BigInt(2 ** 32) const lsb = value % BigInt(2 ** 32) this.write('int', Number(msb)) this.write('int', Number(lsb)) break } case 'utf': { const len = Buffer.byteLength(value, 'utf8') this.write('unsignedShort', len) const start = this.changeBytes(len) this.buffer.write(value, start, len, 'utf8') break } } } result() { return this.buffer.subarray(0, this.position) } } export function encodeTrack(obj) { try { const buf = new EncodeClass() buf.write('byte', 3) buf.write('utf', obj.title) buf.write('utf', obj.author) buf.write('long', BigInt(obj.length)) buf.write('utf', obj.identifier) buf.write('byte', obj.isStream ? 1 : 0) buf.write('byte', obj.uri ? 1 : 0) if (obj.uri) buf.write('utf', obj.uri) buf.write('byte', obj.artworkUrl ? 1 : 0) if (obj.artworkUrl) buf.write('utf', obj.artworkUrl) buf.write('byte', obj.isrc ? 1 : 0) if (obj.isrc) buf.write('utf', obj.isrc) buf.write('utf', obj.sourceName) buf.write('long', BigInt(obj.position)) const buffer = buf.result() const result = Buffer.alloc(buffer.length + 4) result.writeInt32BE(buffer.length | (1 << 30)) buffer.copy(result, 4) return result.toString('base64') } catch { return null } } class DecodeClass { constructor(buffer) { this.position = 0 this.buffer = buffer } changeBytes(bytes) { this.position += bytes return this.position - bytes } read(type) { switch (type) { case 'byte': { return this.buffer[this.changeBytes(1)] } case 'unsignedShort': { const result = this.buffer.readUInt16BE(this.changeBytes(2)) return result } case 'int': { const result = this.buffer.readInt32BE(this.changeBytes(4)) return result } case 'long': { const msb = BigInt(this.read('int')) const lsb = BigInt(this.read('int')) return msb * BigInt(2 ** 32) + lsb } case 'utf': { const len = this.read('unsignedShort') const start = this.changeBytes(len) const result = this.buffer.toString('utf8', start, start + len) return result } } } } export function decodeTrack(track) { try { const buf = new DecodeClass(Buffer.from(track, 'base64')) const version = ((buf.read('int') & 0xC0000000) >> 30 & 1) !== 0 ? buf.read('byte') : 1 switch (version) { case 1: { return { title: buf.read('utf'), author: buf.read('utf'), length: Number(buf.read('long')), identifier: buf.read('utf'), isStream: buf.read('byte') === 1, uri: null, source: buf.read('utf'), position: Number(buf.read('long')) } } case 2: { return { title: buf.read('utf'), author: buf.read('utf'), length: Number(buf.read('long')), identifier: buf.read('utf'), isStream: buf.read('byte') === 1, uri: buf.read('byte') === 1 ? buf.read('utf') : null, source: buf.read('utf'), position: Number(buf.read('long')) } } case 3: { return { title: buf.read('utf'), author: buf.read('utf'), length: Number(buf.read('long')), identifier: buf.read('utf'), isSeekable: true, isStream: buf.read('byte') === 1, uri: buf.read('byte') === 1 ? buf.read('utf') : null, artworkUrl: buf.read('byte') === 1 ? buf.read('utf') : null, isrc: buf.read('byte') === 1 ? buf.read('utf') : null, sourceName: buf.read('utf'), position: Number(buf.read('long')) } } } } catch { return null } } export function debugLog(name, type, options) { switch (type) { case 1: { if (!config.debug.request.enabled) return; if (options.headers) { options.headers.authorization = 'REDACTED' options.headers.host = 'REDACTED' } if (options.error) console.error(`[\u001b[32m${name}\u001b[37m]: Detected an error in a request: \u001b[31m${options.error}\u001b[37m${config.debug.request.showParams && options.params ? `\n Params: ${JSON.stringify(options.params)}` : ''}${config.debug.request.showHeaders && options.headers ? `\n Headers: ${JSON.stringify(options.headers)}` : ''}${config.debug.request.showBody && options.body ? `\n Body: ${JSON.stringify(options.body)}` : ''}`) else console.log(`[\u001b[32m${name}\u001b[37m]: Received a request from client.${config.debug.request.showParams && options.params ? `\n Params: ${JSON.stringify(options.params)}` : ''}${config.debug.request.showHeaders && options.headers ? `\n Headers: ${JSON.stringify(options.headers)}` : ''}${config.debug.request.showBody && options.body ? `\n Body: ${JSON.stringify(options.body)}` : ''}`) break } case 2: { switch (name) { case 'trackStart': { if (!config.debug.track.start) return; console.log(`[\u001b[32mtrackStart\u001b[37m]: \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m.`) break } case 'trackEnd': { if (!config.debug.track.end) return; console.log(`[\u001b[32mtrackEnd\u001b[37m]: \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m because was \u001b[94m${options.reason}\u001b[37m.`) break } case 'trackException': { if (!config.debug.track.exception) return; console.error(`[\u001b[31mtrackException\u001b[37m]: \u001b[94m${options.track?.title || 'None'}\u001b[37m by \u001b[94m${options.track?.author || 'none'}\u001b[37m: \u001b[31m${options.exception}\u001b[37m`) break } case 'trackStuck': { if (!config.debug.track.stuck) return; console.warn(`[\u001b[33mtrackStuck\u001b[37m]: \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m: \u001b[33m${config.options.threshold}ms have passed.\u001b[37m`) break } } break } case 3: { switch (name) { case 'connect': { if (!config.debug.websocket.connect) return; if (options.error) return console.error(`[\u001b[31mwebsocket\u001b[37m]: \u001b[31m${options.error}\u001b[37m\n Name: \u001b[94m${options.name}\u001b[37m`) console.log(`[\u001b[32mwebsocket\u001b[37m]: \u001b[94m${options.name}\u001b[37m@\u001b[94m${options.version}\u001b[37m client connected to NodeLink.`) break } case 'disconnect': { if (!config.debug.websocket.disconnect) return; console.error(`[\u001b[33mwebsocket\u001b[37m]: A connection was closed with a client.\n Code: \u001b[33m${options.code}\u001b[37m\n Reason: \u001b[33m${options.reason === '' ? 'No reason provided' : options.reason}\u001b[37m`) break } case 'error': { if (!config.debug.websocket.error) return; console.error(`[\u001b[31mwebsocketError\u001b[37m]: \u001b[94m${options.name}\u001b[37m@\u001b[94m${options.version}\u001b[37m ran into an error: \u001b[31m${options.error}\u001b[37m`) break } case 'connectCD': { if (!config.debug.websocket.connectCD) return; console.log(`[\u001b[32mwebsocketCD\u001b[37m]: \u001b[94m${options.name}\u001b[37m@\u001b[94m${options.version}\u001b[37m client connected to NodeLink.\n Guild: \u001b[94m${options.guildId}\u001b[37m`) break } case 'disconnectCD': { if (!config.debug.websocket.disconnectCD) return; console.error(`[\u001b[32mwebsocketCD\u001b[37m]: Connection with \u001b[94m${options.name}\u001b[37m@\u001b[94m${options.version}\u001b[37m was closed.\n Guild: \u001b[94m${options.guildId}\u001b[37m\n Code: \u001b[33m${options.code}\u001b[37m\n Reason: \u001b[33m${options.reason === '' ? 'No reason provided' : options.reason}\u001b[37m`) break } case 'sentDataCD': { if (!config.debug.websocket.sentDataCD) return; console.log(`[\u001b[32msentData\u001b[37m]: Sent data to \u001b[94m${options.clientsAmount}\u001b[37m clients.\n Guild: \u001b[94m${options.guildId}\u001b[37m`) break } default: { if (!config.debug.request.error) return; console.error(`[\u001b[31m${name}\u001b[37m]: \u001b[31m${options.error}\u001b[37m${config.debug.request.showParams && options.params ? `\n Params: ${JSON.stringify(options.params)}` : ''}${config.debug.request.showHeaders && options.headers ? `\n Headers: ${JSON.stringify(options.headers)}` : ''}${config.debug.request.showBody && options.body ? `\n Body: ${JSON.stringify(options.body)}` : ''}`) break } } break } case 4: { switch (name) { case 'loadtracks': { if (options.type === 1 && config.debug.sources.loadtrack.request) console.log(`[\u001b[32mloadTracks\u001b[37m]: Loading \u001b[94m${options.loadType}\u001b[37m from ${options.sourceName}: ${options.query}`) if (options.type === 2 && config.debug.sources.loadtrack.results) { if (options.loadType !== 'search' && options.loadType !== 'track') console.log(`[\u001b[32mloadTracks\u001b[37m]: Loaded \u001b[94m${options.playlistName}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m.`) else console.log(`[\u001b[32mloadTracks\u001b[37m]: Loaded \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m: ${options.query}`) } if (options.type === 3 && config.debug.sources.loadtrack.exception) console.error(`[\u001b[31mloadTracks\u001b[37m]: Exception loading \u001b[94m${options.loadType}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m: \u001b[31m${options.message}\u001b[37m`) break } case 'search': { if (options.type === 1 && config.debug.sources.search.request) console.log(`[\u001b[32msearch\u001b[37m]: Searching for \u001b[94m${options.query}\u001b[37m on \u001b[94m${options.sourceName}\u001b[37m`) if (options.type === 2 && config.debug.sources.search.results) console.log(`[\u001b[32msearch\u001b[37m]: Found \u001b[94m${options.tracksLen}\u001b[37m tracks on \u001b[94m${options.sourceName}\u001b[37m for query \u001b[94m${options.query}\u001b[37m`) if (options.type === 3 && config.debug.sources.search.exception) console.error(`[\u001b[31msearch\u001b[37m]: Exception from ${options.sourceName} for query \u001b[94m${options.query}\u001b[37m: \u001b[31m${options.message}\u001b[37m`) break } case 'retrieveStream': { if (!config.debug.sources.retrieveStream) return; if (options.type === 1) console.log(`[\u001b[32mretrieveStream\u001b[37m]: Retrieved from \u001b[94m${options.sourceName}\u001b[37m for query \u001b[94m${options.query}\u001b[37m`) if (options.type === 2) console.error(`[\u001b[31mretrieveStream\u001b[37m]: Exception from \u001b[94m${options.sourceName}\u001b[37m for query \u001b[94m${options.query}\u001b[37m: \u001b[31m${options.message}\u001b[37m`) break } case 'loadlyrics': { if (options.type === 1 && config.debug.sources.loadlyrics.request) console.log(`[\u001b[32mloadCaptions\u001b[37m]: Loading captions for \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m`) if (options.type === 2 && config.debug.sources.loadlyrics.results) console.log(`[\u001b[32mloadCaptions\u001b[37m]: Loaded captions for \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m`) if (options.type === 3 && config.debug.sources.loadlyrics.exception) console.error(`[\u001b[31mloadCaptions\u001b[37m]: Exception loading captions for \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m: \u001b[31m${options.message}\u001b[37m`) break } } break } case 5: { switch (name) { case 'youtube': { if (options.type === 1 && config.debug.youtube.success) console.log(`[\u001b[32myoutube\u001b[37m]: ${options.message}`) if (options.type === 2 && config.debug.youtube.error) console.error(`[\u001b[31myoutube\u001b[37m]: ${options.message}`) break } case 'pandora': { if (options.type === 1 && config.debug.pandora.success) console.log(`[\u001b[32mpandora\u001b[37m]: ${options.message}`) if (options.type === 2 && config.debug.pandora.error) console.error(`[\u001b[31mpandora\u001b[37m]: ${options.message}`) break } case 'deezer': { if (options.type === 1 && config.debug.deezer.success) console.log(`[\u001b[32mdeezer\u001b[37m]: ${options.message}`) if (options.type === 2 && config.debug.deezer.error) console.error(`[\u001b[31mdeezer\u001b[37m]: ${options.message}`) break } case 'spotify': { if (options.type === 1 && config.debug.spotify.success) console.log(`[\u001b[32mspotify\u001b[37m]: ${options.message}`) if (options.type === 2 && config.debug.spotify.error) console.error(`[\u001b[31mspotify\u001b[37m]: ${options.message}`) break } case 'soundcloud': { if (options.type === 1 && config.debug.soundcloud.success) console.log(`[\u001b[32msoundcloud\u001b[37m]: ${options.message}`) if (options.type === 2 && config.debug.soundcloud.error) console.error(`[\u001b[31msoundcloud\u001b[37m]: ${options.message}`) break } case 'musixmatch': { console.log(`[\u001b[32mmusixmatch\u001b[37m]: ${options.message}`) break } } break } case 6: { if (!config.debug.request.all) return; if (options.headers) { options.headers.authorization = 'REDACTED' options.headers.host = 'REDACTED' } console.log(`[\u001b[32mALL\u001b[37m]: Received a request from client.\n Path: ${options.path}${options.params ? `\n Params: ${JSON.stringify(options.params)}` : ''}${options.headers ? `\n Headers: ${JSON.stringify(options.headers)}` : ''}${options.body ? `\n Body: ${JSON.stringify(options.body)}` : ''}`) break } } } export function sendResponse(req, res, data, status) { if (!data) { res.writeHead(status) res.end() return true } if (!req.headers || !req.headers['accept-encoding']) { res.setHeader('Connection', 'close') res.writeHead(status, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(data)) } if (req.headers && req.headers['accept-encoding']) { if (req.headers['accept-encoding'].includes('br')) { res.setHeader('Content-Encoding', 'br') res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Encoding': 'br' }) zlib.brotliCompress(JSON.stringify(data), (err, result) => { if (err) { res.writeHead(500) res.end() return; } res.end(result) }) } else if (req.headers['accept-encoding'].includes('gzip')) { res.setHeader('Content-Encoding', 'gzip') res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' }) zlib.gzip(JSON.stringify(data), (err, result) => { if (err) { res.writeHead(500) res.end() return; } res.end(result) }) } else if (req.headers['accept-encoding'].includes('deflate')) { res.setHeader('Content-Encoding', 'deflate') res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Encoding': 'deflate' }) zlib.deflate(JSON.stringify(data), (err, result) => { if (err) { res.writeHead(500) res.end() return; } res.end(result) }) } } return true } export function tryParseBody(req, res) { return new Promise((resolve) => { let buffer = '' req.on('data', (chunk) => buffer += chunk) req.on('end', () => { try { resolve(JSON.parse(buffer)) } catch { sendResponse(req, res, { timestamp: Date.now(), status: 400, trace: new Error().stack, error: 'Bad Request', message: 'Invalid JSON body', path: req.url }, 400) resolve(null) } }) }) } export function sendResponseNonNull(req, res, data) { if (data === null) return; sendResponse(req, res, data, 200) return true } export function verifyMethod(parsedUrl, req, res, expected) { if (req.method !== expected) { sendResponse(req, res, { timestamp: Date.now(), status: 405, error: 'Method Not Allowed', message: `Request method must be ${expected}`, path: parsedUrl.pathname }, 405) return 1 } return 0 } Array.prototype.nForEach = async function(callback) { return new Promise(async (resolve) => { for (let i = 0; i < this.length - 1; i++) { const res = await callback(this[i], i) if (res) return resolve() } resolve() }) } export function waitForEvent(emitter, eventName, func, timeoutMs) { return new Promise((resolve) => { const timeout = timeoutMs ? setTimeout(() => { throw new Error(`Event ${eventName} timed out after ${timeoutMs}ms`) }, timeoutMs) : null const listener = (param, param2) => { if (func(param, param2) === true) { emitter.removeListener(eventName, listener) timeoutMs ? clearTimeout(timeout) : null resolve() } } emitter.on(eventName, listener) }) } export function clamp16Bit(sample) { return Math.max(constants.pcm.minimumRate, Math.min(sample, constants.pcm.maximumRate)) } export function parseClientName(clientName) { if (!clientName) return null let clientInfo = clientName.split('(') if (clientInfo.length > 1) clientInfo = clientInfo[0].slice(0, clientInfo[0].length - 1) else clientInfo = clientInfo[0] const split = clientInfo.split('/') const name = split[0] const version = split[1] if (!name || !version || split.length != 2) return null return { name, version } } export function isEmpty(value) { return value === undefined || value === null || false } export function loadHLS(url, stream, onceEnded) { return new Promise(async (resolve) => { const response = await http1makeRequest(url, { method: 'GET' }) const body = response.body.split('\n') let segmentMetadata = { duration: 0 } body.nForEach(async (line, i) => { return new Promise(async (resolveSegment) => { if (stream.ended) { resolveSegment(true) return resolve(false) } if (line.startsWith('#')) { const tag = line.split(':')[0] let value = line.split(':')[1] if (value) value = value.split(',')[0] if (tag === '#EXTINF') { segmentMetadata.duration = parseFloat(value) * 1000 } else if (tag === '#EXT-X-ENDLIST') { stream.end() return resolveSegment(true) } return resolveSegment(false) } const now = Date.now() const segment = await http1makeRequest(line, { method: 'GET', streamOnly: true }) segment.stream.on('data', (chunk) => stream.write(chunk)) segment.stream.once('readable', () => { if (segmentMetadata.duration) { setTimeout(() => { resolveSegment(false) }, segmentMetadata.duration - (Date.now() - now) * 2) segmentMetadata.duration = 0 } else { segment.stream.on('end', () => { resolveSegment(false) segment.stream.destroy() }) } }) if (onceEnded && i === body.length - 2) { segment.stream.on('end', () => { resolve(true) segment.stream.destroy() }) } }) }) if (!onceEnded) resolve(true) }) } export function loadHLSPlaylist(url, stream) { return new Promise(async (resolve) => { const response = await http1makeRequest(url, { method: 'GET' }) const body = response.body.split('\n') body.nForEach(async (line, i) => { return new Promise(async (resolvePlaylist) => { if (line.startsWith('#')) { const tag = line.split(':')[0] let value = line.split(':')[1] if (value) value = value.split(',')[0] if (tag === '#EXT-X-ENDLIST') { stream.end() resolvePlaylist(true) return resolve(stream) } resolvePlaylist(false) if (i === body.length - 1) { loadHLSPlaylist(value, stream) resolve(stream) } return; } if (await loadHLS(line, stream, true) === false) return resolve(stream) resolvePlaylist(false) if (i === body.length - 2) { loadHLSPlaylist(url, stream) return resolve(stream) } }) }) resolve(stream) }) }