nodelink / src /connection /handler.js
flameface's picture
Upload 25 files
b58c6cb verified
import os from 'node:os'
import { URL } from 'node:url'
import { randomLetters, debugLog, sendResponse, sendResponseNonNull, verifyMethod, encodeTrack, decodeTrack, tryParseBody } from '../utils.js'
import config from '../../config.js'
import sources from '../sources.js'
import VoiceConnection from './voiceHandler.js'
const clients = {}
let statsInterval = null
let playerUpdateInterval = null
function startStats() {
statsInterval = setInterval(() => {
let memoryUsage = process.memoryUsage()
const statistics = {
sent: 0,
nulled: 0,
expected: 0,
deficit: 0
}
Object.keys(clients).forEach((key) => {
const client = clients[key]
client.players.forEach((player) => {
if (!player.connection) return;
statistics.sent += player.connection.statistics.packetsSent
statistics.nulled += player.connection.statistics.packetsLost
statistics.expected += player.connection.statistics.packetsExpected
})
})
statistics.deficit = statistics.sent - statistics.expected
const statisticsResponse = JSON.stringify({
op: 'stats',
players: nodelinkPlayingPlayersCount,
playingPlayers: nodelinkPlayingPlayersCount,
uptime: Math.floor(process.uptime() * 1000),
memory: {
free: memoryUsage.heapTotal - memoryUsage.heapUsed,
used: memoryUsage.heapUsed,
allocated: 0,
reservable: memoryUsage.rss
},
cpu: {
cores: os.cpus().length,
systemLoad: os.loadavg()[0],
lavalinkLoad: 0
},
frameStats: statistics
})
Object.keys(clients).forEach((key) => clients[key].ws.send(statisticsResponse, 200))
}, config.options.statsInterval)
}
function startPlayerUpdate() {
playerUpdateInterval = setInterval(() => {
if (Object.keys(clients).length === 0) return;
Object.keys(clients).forEach((key) => {
const client = clients[key]
client.players.forEach((player) => {
if (!player.connection) return;
player.config.state = {
time: Date.now(),
position: player.connection.playerState.status === 'playing' ? player._getRealTime() : 0,
connected: player.connection.state.status === 'connected',
ping: player.connection.ping || -1
}
client.ws.send(JSON.stringify({
op: 'playerUpdate',
guildId: player.guildId,
state: player.config.state
}))
})
})
}, config.options.playerUpdateInterval)
}
async function configureConnection(ws, req, parsedClientName) {
let sessionId = null
let client = null
ws.on('close', (code, reason) => {
debugLog('disconnect', 3, { ...parsedClientName, code, reason })
if (!client) return;
if (clients.length === 1) {
clearInterval(statsInterval)
statsInterval = null
clearInterval(playerUpdateInterval)
playerUpdateInterval = null
if (config.search.sources.youtube && config.options.bypassAgeRestriction)
sources.youtube.free()
}
client.players.forEach((player) => player.destroy())
delete clients[sessionId]
})
sessionId = randomLetters(16)
client = {
userId: req.headers['user-id'],
ws,
players: new Map()
}
clients[sessionId] = client
await startSourceAPIs()
ws.send(
JSON.stringify({
op: 'ready',
resumed: false,
sessionId
})
)
}
async function requestHandler(req, res) {
const parsedUrl = new URL(req.url, `http://${req.headers.host}`)
if (config.debug.request.all) {
const body = []
req.on('data', (chunk) => body.push(chunk))
req.on('end', () => {
debugLog('all', 6, { method: req.method, path: parsedUrl.pathname, headers: req.headers, body: Buffer.concat(body).toString() })
req.removeAllListeners()
req.push(Buffer.concat(body))
})
}
if (!req.headers || req.headers.authorization !== config.server.password) {
res.writeHead(401, { 'Content-Type': 'text/plain' })
res.end('Unauthorized')
}
else if (parsedUrl.pathname === '/version') {
if (verifyMethod(parsedUrl, req, res, 'GET')) return;
debugLog('version', 1, { headers: req.headers })
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end(`${config.version.major}.${config.version.minor}.${config.version.patch}${config.version.preRelease ? `-${config.version.preRelease}` : ''}`)
}
else if (parsedUrl.pathname === '/v4/info') {
if (verifyMethod(parsedUrl, req, res, 'GET')) return;
debugLog('info', 1, { headers: req.headers })
sendResponse(req, res, {
version: {
semver: `${config.version.major}.${config.version.minor}.${config.version.patch}${config.version.preRelease ? `-${config.version.preRelease}` : ''}`,
...config.version
},
buildTime: -1,
git: {
branch: 'main',
commit: 'unknown',
commitTime: -1
},
nodejs: process.version,
isNodeLink: true,
jvm: '0.0.0',
lavaplayer: '0.0.0',
sourceManagers: Object.keys(config.search.sources).filter((source) => {
if (typeof config.search.sources[source] === 'boolean') return source
return config.search.sources[source].enabled
}),
filters: Object.keys(config.filters.list).filter((filter) => config.filters.list[filter]),
plugins: []
}, 200)
}
else if (parsedUrl.pathname === '/v4/decodetrack') {
if (verifyMethod(parsedUrl, req, res, 'GET')) return;
let encodedTrack = parsedUrl.searchParams.get('encodedTrack')
if (!encodedTrack) {
debugLog('decodetrack', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided track is invalid.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
error: 'Bad Request',
trace: new Error().stack,
message: 'The provided track is invalid.',
path: parsedUrl.pathname
}, 400)
}
encodedTrack = encodedTrack.replace(/ /, '+')
let decodedTrack = null
if (!encodedTrack || !(decodedTrack = decodeTrack(encodedTrack))) {
debugLog('decodetrack', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided track is invalid.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
error: 'Bad Request',
trace: new Error().stack,
message: 'The provided track is invalid.',
path: parsedUrl.pathname
}, 400)
}
debugLog('decodetrack', 1, { params: parsedUrl.pathname, headers: req.headers })
sendResponse(req, res, { encoded: encodedTrack, info: decodedTrack }, 200)
}
else if (parsedUrl.pathname === '/v4/decodetracks') {
if (verifyMethod(parsedUrl, req, res, 'POST')) return;
let buffer = ''
if (!(buffer = await tryParseBody(req, res))) return;
if (typeof buffer !== 'object' || !Array.isArray(buffer)) {
debugLog('decodetracks', 1, { headers: req.headers, body: buffer, error: 'The provided body is invalid.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
error: 'Bad request',
trace: new Error().stack,
message: 'The provided body is invalid.',
path: parsedUrl.pathname
}, 400)
}
const tracks = []
let failed = false
buffer.nForEach((encodedTrack) => {
const decodedTrack = decodeTrack(encodedTrack)
if (!decodedTrack) {
failed = true
debugLog('decodetracks', 1, { headers: req.headers, body: encodedTrack, error: 'The provided track is invalid.' })
sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
error: 'Bad request',
trace: new Error().stack,
message: 'The provided track is invalid.',
path: parsedUrl.pathname
}, 400)
return true
}
tracks.push({ encoded: encodedTrack, info: decodedTrack })
})
if (failed) return;
debugLog('decodetracks', 1, { headers: req.headers, body: buffer })
sendResponse(req, res, tracks, 200)
}
else if (parsedUrl.pathname === '/v4/encodetrack') {
if (verifyMethod(parsedUrl, req, res, 'GET')) return;
let buffer = ''
if (!(buffer = await tryParseBody(req, res))) return;
let encodedTrack = null
if (!(encodedTrack = encodeTrack(buffer))) {
debugLog('encodetrack', 1, { headers: req.headers, body: buffer, error: 'Invalid track object' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
error: 'Bad Request',
trace: new Error().stack,
message: 'Invalid track object',
path: '/v4/encodetrack'
}, 400)
}
debugLog('encodetrack', 1, { headers: req.headers, body: buffer })
sendResponse(req, res, encodedTrack, 200)
}
else if (parsedUrl.pathname === '/v4/encodetracks') {
if (verifyMethod(parsedUrl, req, res, 'POST')) return;
let buffer = ''
if (!(buffer = await tryParseBody(req, res))) return;
if (typeof buffer !== 'object' || !Array.isArray(buffer)) {
debugLog('decodetracks', 1, { headers: req.headers, body: buffer, error: 'The provided body is invalid.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
error: 'Bad request',
trace: new Error().stack,
message: 'The provided body is invalid.',
path: parsedUrl.pathname
}, 400)
}
const tracks = []
buffer.forEach((track) => {
let encodedTrack = null
if (!(encodedTrack = encodeTrack(track))) {
debugLog('encodetracks', 1, { headers: req.headers, body: buffer, error: 'Invalid track object' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
error: 'Bad Request',
trace: new Error().stack,
message: 'Invalid track object',
path: '/v4/encodetracks'
}, 400)
}
tracks.push(encodedTrack)
})
debugLog('encodetracks', 1, { headers: req.headers, body: buffer })
sendResponse(req, res, tracks, 200)
}
else if (parsedUrl.pathname === '/v4/stats') {
if (verifyMethod(parsedUrl, req, res, 'GET')) return;
debugLog('stats', 1, { headers: req.headers })
const statistics = {
sent: 0,
nulled: 0,
expected: 0,
deficit: 0
}
Object.keys(clients).forEach((key) => {
const client = clients[key]
client.players.forEach((player) => {
if (!player.connection) return;
statistics.sent += player.connection.statistics.packetsSent
statistics.nulled += player.connection.statistics.packetsLost
statistics.expected += player.connection.statistics.packetsExpected
})
})
statistics.deficit = statistics.sent - statistics.expected
sendResponse(req, res, {
players: nodelinkPlayersCount,
playingPlayers: nodelinkPlayingPlayersCount,
uptime: Math.floor(process.uptime() * 1000),
memory: {
free: process.memoryUsage().heapTotal - process.memoryUsage().heapUsed,
used: process.memoryUsage().heapUsed,
allocated: 0,
reservable: process.memoryUsage().rss
},
cpu: {
cores: os.cpus().length,
systemLoad: os.loadavg()[0],
lavalinkLoad: 0
},
frameStats: statistics
}, 200)
}
else if (parsedUrl.pathname === '/v4/loadtracks') {
if (verifyMethod(parsedUrl, req, res, 'GET')) return;
debugLog('loadtracks', 1, { params: parsedUrl.pathname, headers: req.headers })
const search = await sources.loadTracks(parsedUrl.searchParams.get('identifier'))
sendResponse(req, res, search, 200)
return;
}
else if (parsedUrl.pathname === '/v4/loadlyrics') {
if (verifyMethod(parsedUrl, req, res, 'GET')) return;
const encodedTrack = parsedUrl.searchParams.get('encodedTrack')
let decodedTrack = null
if (!encodedTrack || !(decodedTrack = decodeTrack(encodedTrack))) {
debugLog('loadlyrics', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided track is invalid.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
error: 'Bad Request',
trace: new Error().stack,
message: 'The provided track is invalid.',
path: '/v4/loadlyrics'
}, 400)
}
const language = parsedUrl.searchParams.get('language')
const captions = await sources.loadLyrics(parsedUrl, req, decodedTrack, language)
debugLog('loadlyrics', 1, { params: parsedUrl.pathname, headers: req.headers })
sendResponse(req, res, captions, 200)
}
else if (/^\/v4\/sessions\/[A-Za-z0-9]+\/players$(?!\/)/.test(parsedUrl.pathname)) {
if (verifyMethod(parsedUrl, req, res, 'GET')) return;
const client = clients[/^\/v4\/sessions\/([A-Za-z0-9]+)\/players$/.exec(parsedUrl.pathname)[1]]
if (!client) {
debugLog('getPlayers', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided session Id doesn\'t exist.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 404,
trace: new Error().stack,
message: 'The provided session Id doesn\'t exist.',
path: parsedUrl.pathname
}, 404)
}
const players = []
client.players.forEach((player) => {
player.config.state = {
time: Date.now(),
position: player.connection ? player.connection.playerState.status === 'playing' ? player._getRealTime() : 0 : 0,
connected: player.connection ? player.connection.state.status === 'ready' : false,
ping: player.connection?.ping || -1
}
players.push(player.config)
})
debugLog('getPlayers', 1, { headers: req.headers })
sendResponse(req, res, players, 200)
}
else if (/^\/v4\/sessions\/\w+\/players\/\w+./.test(parsedUrl.pathname)) {
if (![ 'DELETE', 'PATCH', 'GET' ].includes(req.method)) {
sendResponse(req, res, {
timestamp: Date.now(),
status: 405,
error: 'Method Not Allowed',
message: `Request method must be DELETE, PATCH or GET`,
path: parsedUrl.pathname
}, 405)
return;
}
const client = clients[/^\/v4\/sessions\/([A-Za-z0-9]+)\/players\/\d+$/.exec(parsedUrl.pathname)[1]]
if (!client) {
debugLog('updatePlayer', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided session Id doesn\'t exist.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 404,
trace: new Error().stack,
message: 'The provided session Id doesn\'t exist.',
path: parsedUrl.pathname
}, 404)
}
const guildId = /\/players\/(\d+)$/.exec(parsedUrl.pathname)[1]
let player = client.players.get(guildId)
if (req.method === 'DELETE') {
if (!player) {
debugLog('deletePlayer', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided guildId doesn\'t exist.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 404,
trace: new Error().stack,
message: 'The provided guildId doesn\'t exist.',
path: parsedUrl.pathname
}, 404)
}
player.destroy()
client.players.delete(guildId)
debugLog('deletePlayer', 1, { params: parsedUrl.pathname, headers: req.headers })
return sendResponse(req, res, null, 204)
}
if (req.method === 'GET') {
if (!guildId) {
debugLog('getPlayer', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'Missing guildId parameter.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
trace: new Error().stack,
message: 'Missing guildId parameter.',
path: parsedUrl.pathname
}, 400)
}
let player = client.players.get(guildId)
if (!player) {
player = new VoiceConnection(guildId, client)
client.players.set(guildId, player)
}
player.config.state = {
time: Date.now(),
position: player.connection ? player.connection.playerState.status === 'playing' ? player._getRealTime() : 0 : 0,
connected: player.connection ? player.connection.state.status === 'ready' : false,
ping: player.connection?.ping || -1
}
debugLog('getPlayer', 1, { params: parsedUrl.pathname, headers: req.headers })
return sendResponse(req, res, player.config, 200)
}
let buffer = ''
if (!(buffer = await tryParseBody(req, res))) return;
if (req.method === 'PATCH') {
if (!player) player = new VoiceConnection(guildId, client)
if (buffer.voice !== undefined) {
if (!buffer.voice.endpoint || !buffer.voice.token || !buffer.voice.sessionId) {
debugLog('voice', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: `Invalid voice object.` })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
trace: new Error().stack,
message: 'Invalid voice object.',
path: parsedUrl.pathname
}, 400)
}
player.updateVoice(buffer.voice)
if (player.cache.track) {
player.play(player.cache.track, decodeTrack(player.cache.track), false)
player.cache.track = null
}
client.players.set(guildId, player)
debugLog('voice', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
}
/* Deprecated */
const encodedTrack = buffer.track?.encoded === undefined ? buffer.encodedTrack : buffer.track?.encoded
if (encodedTrack !== undefined) {
if (buffer.encodedTrack !== undefined) /* Deprecated */
debugLog('encodedTrack', 2, { params: parsedUrl.pathname, headers: req.headers, body: buffer, warning: 'The client is using a deprecated method of play (encodedTrack), deprecated by LavaLink. Report to the client GitHub.' })
if (encodedTrack === null) {
if (!player.config.track) {
debugLog('stop', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The player is not playing.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
trace: new Error().stack,
message: 'The player is not playing.',
path: parsedUrl.pathname
}, 400)
}
player.stop()
debugLog('stop', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
} else {
const noReplace = parsedUrl.searchParams.get('noReplace')
const decodedTrack = decodeTrack(encodedTrack)
if (!decodedTrack) {
debugLog('play', 1, { track: encodedTrack, exception: { message: 'The provided track is invalid.', severity: 'common', cause: 'Invalid track' } })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
trace: new Error().stack,
message: 'The provided track is invalid.',
path: parsedUrl.pathname
}, 400)
}
if (!player.connection.voiceServer) player.cache.track = encodedTrack
else player.play(encodedTrack, decodedTrack, noReplace === true)
debugLog('play', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
}
client.players.set(guildId, player)
}
if (buffer.track?.userData !== undefined) {
player.config.track = {
...(player.config.track ? player.config.track : {}),
userData: buffer.userData
}
debugLog('userData', 1, { params: parsedUrl.pathname, params: parsedUrl.pathname, body: buffer })
}
if (buffer.volume !== undefined) {
if (buffer.volume < 0 || buffer.volume > 1000) {
debugLog('volume', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The volume must be between 0 and 1000.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
trace: new Error().stack,
message: 'The volume must be between 0 and 1000.',
path: parsedUrl.pathname
}, 400)
}
player.volume(buffer.volume)
client.players.set(guildId, player)
debugLog('volume', 1, { params: parsedUrl.pathname, params: parsedUrl.pathname, body: buffer })
}
if (buffer.paused !== undefined) {
if (typeof buffer.paused !== 'boolean') {
debugLog('pause', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The paused value must be a boolean.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
trace: new Error().stack,
message: 'The paused value must be a boolean.',
path: parsedUrl.pathname
}, 400)
}
if (!player.connection?.ws) {
debugLog('pause', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The player is not connected to a voice server.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
trace: new Error().stack,
message: 'The player is not connected to a voice server.',
path: parsedUrl.pathname
}, 400)
}
player.pause(buffer.paused)
client.players.set(guildId, player)
debugLog('pause', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
}
let filters = {}
if (buffer.filters !== undefined) {
if (typeof buffer.filters !== 'object') {
debugLog('filters', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The filters value must be an object.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
trace: new Error().stack,
message: 'The filters value must be an object.',
path: parsedUrl.pathname
}, 400)
}
filters = buffer.filters
debugLog('filters', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
}
if (buffer.position !== undefined) {
if (typeof buffer.position !== 'number' && buffer.endTime !== null) {
debugLog('seek', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The position value must be a number.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
trace: new Error().stack,
message: 'The position value must be a number.',
path: parsedUrl.pathname
}, 400)
}
filters.seek = buffer.position
debugLog('seek', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
}
if (buffer.endTime !== undefined) {
if (typeof buffer.endTime !== 'number' && buffer.endTime !== null) {
debugLog('endTime', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The endTime value must be a number.' })
return sendResponse(req, res, {
timestamp: Date.now(),
status: 400,
trace: new Error().stack,
message: 'The endTime value must be a number.',
path: parsedUrl.pathname
}, 400)
}
filters.endTime = buffer.endTime
debugLog('endTime', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
}
if (Object.keys(filters).length != 0 || JSON.stringify(buffer.filters) === '{}') {
player.filters(filters)
client.players.set(guildId, player)
}
/* Updating player state to ensure it's sending up-to-date data */
player.config.state = {
time: Date.now(),
position: player.connection ? player.connection.playerState.status === 'playing' ? player._getRealTime() : 0 : 0,
connected: player.connection ? player.connection.state.status === 'ready' : false,
ping: player.connection?.ping || -1
}
sendResponse(req, res, player.config, 200)
}
}
else {
sendResponse(req, res, {
timestamp: Date.now(),
status: 404,
error: 'Not Found',
trace: new Error().stack,
message: 'The requested route was not found.',
path: parsedUrl.pathname
}, 404)
}
}
function startSourceAPIs() {
if (Object.keys(clients).length !== 1) return;
return new Promise((resolve) => {
const sourcesToInitialize = []
if (config.search.sources.youtube && config.options.bypassAgeRestriction)
sourcesToInitialize.push(sources.youtube)
if (config.search.sources.spotify.enabled)
sourcesToInitialize.push(sources.spotify)
if (config.search.sources.pandora)
sourcesToInitialize.push(sources.pandora)
if (config.search.sources.deezer.enabled)
sourcesToInitialize.push(sources.deezer)
if (config.search.sources.soundcloud.enabled)
sourcesToInitialize.push(sources.soundcloud)
if (config.options.statsInterval)
startStats()
if (config.options.playerUpdateInterval)
startPlayerUpdate()
if (config.search.sources.musixmatch.enabled)
sources.musixmatch.init()
if (sourcesToInitialize.length === 0) resolve()
let i = 0
sourcesToInitialize.forEach(async (source) => {
await source.init()
if (++i === sourcesToInitialize.length) resolve()
})
})
}
export default {
configureConnection,
requestHandler,
startSourceAPIs
}