nodelink / src /utils.js
flameface's picture
Upload 25 files
b58c6cb verified
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)
})
}