aqualink
Version:
An Lavalink client, focused in pure performance and features
400 lines (342 loc) • 11.3 kB
JavaScript
const { Agent: HttpsAgent, request: httpsRequest } = require('node:https');
const { Agent: HttpAgent, request: httpRequest } = require('node:http');
const { URL } = require('node:url');
const JSON_TYPE_REGEX = /^application\/json/i;
const MAX_RESPONSE_SIZE = 10485760; // 10MB
const BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
const METADATA_PHRASES = [
'Official Visualizer',
'Official',
'Official Video',
'Music Video',
'Live',
'Lyrics',
'Audio',
'HD',
'Remix',
'Cover',
'Acoustic',
'Instrumental',
'Karaoke',
'ft',
'feat'
];
const METADATA_REGEX = new RegExp(
`\\s*[[({](${METADATA_PHRASES.map(phrase =>
phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
).join('|')})[^\\]}()]*[\\])}]`,
'gi'
);
const BRACKETS_REGEX = /\s*(\[[^\]]*\]|\([^\)]*\)|\{[^\}]*\})\s*/g;
const ERRORS = {
NO_SESSION: 'Session ID required',
INVALID_TRACK: 'Invalid encoded track format',
INVALID_TRACKS: 'One or more tracks have invalid format',
RESPONSE_TOO_LARGE: 'Response too large',
JSON_PARSE: 'JSON parse error: ',
REQUEST_TIMEOUT: 'Request timeout: '
};
const cleanTitle = title => title
.replace(METADATA_REGEX, '')
.replace(BRACKETS_REGEX, '')
.trim();
const isValidBase64 = str => {
if (typeof str !== 'string' || str.length === 0) return false;
const len = str.length;
if (len % 4 !== 0) return false;
if (!BASE64_REGEX.test(str)) return false;
let pad = 0;
for (let i = len - 1; i >= 0; i--) {
if (str[i] !== '=') break;
pad++;
}
return pad <= 2;
};
class Rest {
constructor(aqua, { secure = false, host, port, sessionId = null, password, timeout = 15000 }) {
this.aqua = aqua;
this.sessionId = sessionId;
this.version = 'v4';
this.secure = secure;
this.timeout = timeout;
this.baseUrl = new URL(`${secure ? 'https' : 'http'}://${host}:${port}`);
this.headers = Object.freeze({
'Content-Type': 'application/json',
'Authorization': password,
'Accept': 'application/json'
});
const AgentClass = secure ? HttpsAgent : HttpAgent;
this.agent = new AgentClass({
keepAlive: true,
maxSockets: 10,
maxFreeSockets: 5,
timeout: this.timeout,
freeSocketTimeout: 30000,
keepAliveMsecs: 1000,
scheduling: 'fifo'
});
this.request = secure ? httpsRequest : httpRequest;
}
setSessionId(sessionId) {
this.sessionId = sessionId;
}
_validateSessionId() {
if (!this.sessionId) throw new Error(ERRORS.NO_SESSION);
}
_isValidEncodedTrack(track) {
return isValidBase64(track);
}
async makeRequest(method, endpoint, body = null) {
const url = new URL(endpoint, this.baseUrl);
const options = {
method,
headers: this.headers,
timeout: this.timeout,
agent: this.agent
};
return new Promise((resolve, reject) => {
const req = this.request(url, options, res => {
if (res.statusCode === 204) {
res.resume();
return resolve(null);
}
const chunks = [];
let totalLength = 0;
const isJson = JSON_TYPE_REGEX.test(res.headers['content-type'] || '');
res.on('data', chunk => {
totalLength += chunk.length;
if (totalLength > MAX_RESPONSE_SIZE) {
req.destroy();
return reject(new Error(ERRORS.RESPONSE_TOO_LARGE));
}
chunks.push(chunk);
});
res.on('end', () => {
if (totalLength === 0) return resolve(null);
const data = Buffer.concat(chunks);
if (!isJson) return resolve(data.toString());
try {
resolve(JSON.parse(data));
} catch (err) {
reject(new Error(`${ERRORS.JSON_PARSE}${err.message}`));
}
});
res.on('error', reject);
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error(`${ERRORS.REQUEST_TIMEOUT}${this.timeout}ms`));
});
if (body) {
const payload = typeof body === 'string' ? body : JSON.stringify(body);
req.setHeader('Content-Length', Buffer.byteLength(payload));
req.write(payload);
}
req.end();
});
}
async batchRequests(requests) {
return Promise.all(requests.map(({ method, endpoint, body }) =>
this.makeRequest(method, endpoint, body)
));
}
async updatePlayer({ guildId, data }) {
this._validateSessionId();
return this.makeRequest(
'PATCH',
`/${this.version}/sessions/${this.sessionId}/players/${guildId}?noReplace=false`,
data
);
}
async getPlayer(guildId) {
this._validateSessionId();
return this.makeRequest(
'GET',
`/${this.version}/sessions/${this.sessionId}/players/${guildId}`
);
}
async getPlayers() {
this._validateSessionId();
return this.makeRequest(
'GET',
`/${this.version}/sessions/${this.sessionId}/players`
);
}
async destroyPlayer(guildId) {
this._validateSessionId();
return this.makeRequest(
'DELETE',
`/${this.version}/sessions/${this.sessionId}/players/${guildId}`
);
}
async loadTracks(identifier) {
const params = new URLSearchParams({ identifier });
return this.makeRequest(
'GET',
`/${this.version}/loadtracks?${params}`
);
}
async decodeTrack(encodedTrack) {
if (!this._isValidEncodedTrack(encodedTrack)) {
throw new Error(ERRORS.INVALID_TRACK);
}
const params = new URLSearchParams({ encodedTrack });
return this.makeRequest('GET', `/${this.version}/decodetrack?${params}`);
}
async decodeTracks(encodedTracks) {
const invalid = encodedTracks.find(t => !this._isValidEncodedTrack(t));
if (invalid) throw new Error(ERRORS.INVALID_TRACKS);
return this.makeRequest('POST', `/${this.version}/decodetracks`, encodedTracks);
}
// Server info and management
async getStats() {
return this.makeRequest('GET', `/${this.version}/stats`);
}
async getInfo() {
return this.makeRequest('GET', `/${this.version}/info`);
}
async getVersion() {
return this.makeRequest('GET', `/${this.version}/version`);
}
async getRoutePlannerStatus() {
return this.makeRequest('GET', `/${this.version}/routeplanner/status`);
}
async freeRoutePlannerAddress(address) {
return this.makeRequest(
'POST',
`/${this.version}/routeplanner/free/address`,
{ address }
);
}
async freeAllRoutePlannerAddresses() {
return this.makeRequest(
'POST',
`/${this.version}/routeplanner/free/all`
);
}
// Optimized lyrics handling
async getLyrics({ track, skipTrackSource = false }) {
if (!this._isValidTrackForLyrics(track)) {
this.aqua.emit('error', '[Aqua/Lyrics] Invalid track object');
return null;
}
// Priority 1: Current player track
if (track.guild_id) {
try {
const lyrics = await this._getPlayerTrackLyrics(track.guild_id, skipTrackSource);
if (this._isValidLyrics(lyrics)) return lyrics;
} catch (error) {
this.aqua.emit('debug', `[Aqua/Lyrics] Player track failed: ${error.message}`);
}
}
// Priority 2: Encoded track
if (track.encoded && this._isValidEncodedTrack(track.encoded)) {
try {
const lyrics = await this._getEncodedTrackLyrics(track.encoded, skipTrackSource);
if (this._isValidLyrics(lyrics)) return lyrics;
} catch (error) {
this.aqua.emit('debug', `[Aqua/Lyrics] Encoded track failed: ${error.message}`);
}
}
// Priority 3: Title/author search
if (track.info?.title) {
try {
const query = this._buildSearchQuery(track.info);
const lyrics = await this._searchLyrics(query);
if (this._isValidLyrics(lyrics)) return lyrics;
} catch (error) {
this.aqua.emit('debug', `[Aqua/Lyrics] Search failed: ${error.message}`);
}
}
// Priority 4: Cleaned title search
if (track.info?.title && track.info?.author) {
try {
const cleanedTitle = cleanTitle(track.info.title);
const query = `${cleanedTitle} ${track.info.author}`;
const lyrics = await this._searchLyrics(query);
if (this._isValidLyrics(lyrics)) return lyrics;
} catch (error) {
this.aqua.emit('debug', `[Aqua/Lyrics] Clean search failed: ${error.message}`);
}
}
this.aqua.emit('debug', '[Aqua/Lyrics] No lyrics found');
return null;
}
async _getPlayerTrackLyrics(guildId, skipTrackSource) {
this._validateSessionId();
const params = new URLSearchParams({
skipTrackSource: skipTrackSource ? 'true' : 'false'
});
return this.makeRequest(
'GET',
`/${this.version}/sessions/${this.sessionId}/players/${guildId}/track/lyrics?${params}`
);
}
async _getEncodedTrackLyrics(encodedTrack, skipTrackSource) {
const params = new URLSearchParams({
track: encodedTrack,
skipTrackSource: skipTrackSource ? 'true' : 'false'
});
return this.makeRequest('GET', `/${this.version}/lyrics?${params}`);
}
async _searchLyrics(query) {
const params = new URLSearchParams({ query });
return this.makeRequest('GET', `/${this.version}/lyrics/search?${params}`);
}
_isValidTrackForLyrics(track) {
return track && (
track.guild_id ||
(track.encoded && this._isValidEncodedTrack(track.encoded)) ||
track.info?.title
);
}
_isValidLyrics(response) {
return response &&
response.status !== 204 &&
!(Array.isArray(response) && response.length === 0);
}
_buildSearchQuery(info) {
return [info.title, info.author]
.filter(Boolean)
.join(' ');
}
// Live lyrics management
async subscribeLiveLyrics(guildId, skipTrackSource = false) {
this._validateSessionId();
try {
const params = new URLSearchParams({
skipTrackSource: skipTrackSource ? 'true' : 'false'
});
const result = await this.makeRequest(
'POST',
`/${this.version}/sessions/${this.sessionId}/players/${guildId}/lyrics/subscribe?${params}`
);
return result === null;
} catch (error) {
this.aqua.emit('debug', `[Aqua/Lyrics] Subscribe failed: ${error.message}`);
return false;
}
}
async unsubscribeLiveLyrics(guildId) {
this._validateSessionId();
try {
const result = await this.makeRequest(
'DELETE',
`/${this.version}/sessions/${this.sessionId}/players/${guildId}/lyrics/subscribe`
);
return result === null;
} catch (error) {
this.aqua.emit('debug', `[Aqua/Lyrics] Unsubscribe failed: ${error.message}`);
return false;
}
}
// Resource cleanup
destroy() {
if (this.agent) {
this.agent.destroy();
this.agent = null;
}
}
}
module.exports = Rest;