euralink
Version:
🎵 The most advanced, blazing-fast Lavalink client for Node.js with SponsorBlock, real-time lyrics, 60% less RAM usage, and the ultimate music bot experience.
1,052 lines (897 loc) • 35.6 kB
JavaScript
const { EventEmitter } = require("tseep");
const { ActivityType } = require('discord.js');
const { Connection } = require("./Connection");
const { Filters } = require("./Filters");
const { Queue } = require("./Queue");
const { spAutoPlay, scAutoPlay } = require('../handlers/autoPlay');
const { inspect } = require("util");
let lrclibClient = null;
try {
const { Client } = require('lrclib-api');
lrclibClient = new Client();
} catch (error) {
console.warn('lrclib-api not installed. Lyrics functionality will be disabled.');
}
class Player extends EventEmitter {
constructor(eura, node, options) {
super();
this.eura = eura;
this.node = node;
this.options = options;
this.guildId = options.guildId;
this.textChannel = options.textChannel;
this.voiceChannel = options.voiceChannel;
this.connection = new Connection(this);
this.filters = new Filters(this);
this.mute = options.mute ?? false;
this.deaf = options.deaf ?? false;
this.volume = options.defaultVolume ?? 100;
this.loop = options.loop ?? "none";
this.data = {};
this.queue = new Queue();
this.position = 0;
this.current = null;
this.previousTracks = [];
this.historyLimit = options.historyLimit || 20; // NEW: configurable history size
this.playing = false;
this.paused = false;
this.connected = false;
this.timestamp = Date.now();
this.ping = 0;
this.isAutoplay = false;
this.updateQueue = [];
this.updateTimeout = null;
this.batchUpdates = true;
this.batchDelay = 25;
this.autoResumeState = {
enabled: options.autoResume ?? false,
lastTrack: null,
lastPosition: 0,
lastVolume: this.volume,
lastFilters: null,
lastUpdate: Date.now()
};
// SponsorBlock support
this.sponsorBlock = {
enabled: options.sponsorBlock?.enabled ?? false,
categories: options.sponsorBlock?.categories ?? ['sponsor', 'selfpromo', 'interaction'],
segments: [],
chapters: []
};
this.on("playerUpdate", (packet) => {
this.connected = packet.state.connected;
this.position = packet.state.position;
this.ping = packet.state.ping;
this.timestamp = packet.state.time || Date.now();
this.eura.emit("playerUpdate", this, packet);
});
this.on("event", (data) => {
this.handleEvent(data)
});
// SponsorBlock event handling
this.on("SegmentsLoaded", (data) => {
this.sponsorBlock.segments = data.segments || [];
this.eura.emit("sponsorBlockSegmentsLoaded", this, data.segments);
});
this.on("SegmentSkipped", (data) => {
this.eura.emit("sponsorBlockSegmentSkipped", this, data.segment);
});
this.on("ChaptersLoaded", (data) => {
this.sponsorBlock.chapters = data.chapters || [];
this.eura.emit("chaptersLoaded", this, data.chapters);
});
this.on("ChapterStarted", (data) => {
this.eura.emit("chapterStarted", this, data.chapter);
});
}
/**
* @description gets the Previously played Track
*/
get previous() {
return this.previousTracks?.[0]
}
/**
* @description Fetch lyrics for the current track using lrclib-api, or a custom query
* @param {Object|null} queryOverride - Optional custom query { track_name, artist_name, album_name }
* @returns {Promise<{lyrics?: string, syncedLyrics?: string, error?: string, metadata?: Object}>}
*/
async getLyrics(queryOverride = null) {
if (!this.current && !queryOverride) {
return { error: 'No track is currently playing.' };
}
if (!lrclibClient) {
return { error: 'Lyrics functionality not available. Install lrclib-api package.' };
}
try {
let query;
if (queryOverride) {
query = { ...queryOverride };
} else {
const info = this.current.info;
let author = info.author;
// Fallback: try requester username if author is missing
if (!author && info.requester && info.requester.username) {
author = info.requester.username;
}
// Fallback: try 'Unknown Artist' if still missing
if (!author) {
author = 'Unknown Artist';
}
query = {
track_name: info.title,
artist_name: author
};
if (info.pluginInfo?.albumName) {
query.album_name = info.pluginInfo.albumName;
}
}
// Log the query for debugging
this.eura.emit('debug', this.guildId, `Lyrics query: ${JSON.stringify(query)}`);
if (!query.track_name || !query.artist_name) {
return { error: 'Track information incomplete.' };
}
// Fetch metadata (contains both plain and synced lyrics if available)
const meta = await lrclibClient.findLyrics(query);
if (!meta) {
return { error: 'Lyrics not found for this track.' };
}
const result = {
metadata: {
id: meta.id,
trackName: meta.trackName,
artistName: meta.artistName,
albumName: meta.albumName,
duration: meta.duration,
instrumental: meta.instrumental
}
};
// Prefer synced lyrics if available
if (meta.syncedLyrics) {
result.syncedLyrics = meta.syncedLyrics;
result.lyrics = meta.plainLyrics;
} else if (meta.plainLyrics) {
result.lyrics = meta.plainLyrics;
} else {
return { error: 'No lyrics available for this track.' };
}
return result;
} catch (error) {
this.eura.emit('debug', this.guildId, `Lyrics fetch error: ${error.message}`);
return { error: `Failed to fetch lyrics: ${error.message}` };
}
}
/**
* @description Get the current lyric line based on playback position (for synced lyrics)
* @param {string} syncedLyrics - LRC formatted lyrics string
* @param {number} currentTimeMs - Current playback position in milliseconds
* @returns {string} Current lyric line or empty string
*/
getCurrentLyricLine(syncedLyrics, currentTimeMs = this.position) {
if (!syncedLyrics || !currentTimeMs) {
return '';
}
try {
// Simple LRC parser for current line
const lines = syncedLyrics.split('\n');
let currentLine = '';
for (const line of lines) {
const timeMatch = line.match(/\[(\d{2}):(\d{2})\.(\d{2})\]/);
if (timeMatch) {
const minutes = parseInt(timeMatch[1]);
const seconds = parseInt(timeMatch[2]);
const centiseconds = parseInt(timeMatch[3]);
const lineTimeMs = (minutes * 60 + seconds) * 1000 + centiseconds * 10;
if (currentTimeMs >= lineTimeMs) {
currentLine = line.replace(/\[\d{2}:\d{2}\.\d{2}\]/, '').trim();
} else {
break; // Found the next line, stop searching
}
}
}
return currentLine;
} catch (error) {
this.eura.emit('debug', this.guildId, `Lyric line parsing error: ${error.message}`);
return '';
}
}
/**
* @private
*/
addToPreviousTrack(track) {
if (!track) return;
// Attach metadata
const now = Date.now();
let historyEntry = {
...track,
playedAt: now,
replayCount: 1
};
// If this track is already the most recent, increment replayCount
if (this.previousTracks.length > 0 && this.previousTracks[0].info.identifier === track.info.identifier) {
this.previousTracks[0].replayCount += 1;
this.previousTracks[0].playedAt = now;
} else {
this.previousTracks.unshift(historyEntry);
// Enforce history size limit
if (this.previousTracks.length > this.historyLimit) {
this.previousTracks = this.previousTracks.slice(0, this.historyLimit);
}
}
}
/**
* @description Get the full track history (recently played)
* @returns {Array} Array of track history entries
*/
getHistory() {
return this.previousTracks;
}
/**
* Get all favorite tracks from history.
* @returns {Array} Array of favorited tracks
*/
getFavorites() {
return this.previousTracks.filter(track => track.favorited);
}
/**
* Get unique artists and sources from queue and history.
* @returns {Object} { artists: Set, sources: Set }
*/
getUniqueArtistsAndSources() {
const artists = new Set();
const sources = new Set();
for (const track of [...this.queue, ...this.previousTracks]) {
if (track.info?.author) artists.add(track.info.author);
if (track.info?.sourceName) sources.add(track.info.sourceName);
}
return { artists, sources };
}
queueUpdate(updateData) {
if (!this.batchUpdates) {
this.performUpdate(updateData);
return;
}
this.updateQueue.push({
...updateData,
timestamp: Date.now()
});
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
}
this.updateTimeout = setTimeout(() => {
this.processUpdateQueue();
}, this.batchDelay);
}
async processUpdateQueue() {
if (this.updateQueue.length === 0) return;
const mergedUpdate = {};
for (const update of this.updateQueue) {
Object.assign(mergedUpdate, update);
}
this.updateQueue = [];
try {
await this.performUpdate(mergedUpdate);
} catch (error) {
this.eura.emit('playerError', this, error);
}
}
async performUpdate(updateData) {
try {
await this.node.rest.updatePlayer({
guildId: this.guildId,
data: updateData,
});
} catch (error) {
this.eura.emit('playerError', this, error);
throw error;
}
}
async play() {
try {
if (!this.connected) {
throw new Error("Player connection is not initiated. Kindly use Euralink.createConnection() and establish a connection, TIP: Check if Guild Voice States intent is set/provided & 'updateVoiceState' is used in the raw(Gateway Raw) event");
}
if (!this.queue.length) return;
this.current = this.queue.shift();
if (!this.current.track) {
this.current = await this.current.resolve(this.eura);
}
this.playing = true;
this.position = 0;
this.timestamp = Date.now();
const { track } = this.current;
this.queueUpdate({
track: {
encoded: track,
},
});
return this;
} catch (err) {
this.eura.emit('playerError', this, err);
throw err;
}
}
async restart() {
try {
if (!this.current || !this.connected) return;
// Use saved position from autoResumeState if available, otherwise use current position
const resumePosition = this.autoResumeState.lastPosition || this.position;
const data = {
track: { encoded: this.current.track },
position: resumePosition,
paused: this.paused,
volume: this.volume,
};
if (this.filters && typeof this.filters.getPayload === "function") {
const filterPayload = this.filters.getPayload();
if (filterPayload && Object.keys(filterPayload).length > 0) {
data.filters = filterPayload;
}
}
await this.node.rest.updatePlayer({
guildId: this.guildId,
data,
});
// Update the position to match what we sent to Lavalink
this.position = resumePosition;
this.playing = !this.paused;
this.autoResumeState.lastUpdate = Date.now();
this.eura.emit("debug", this.guildId, `Player state restored after node reconnect (autoResume) at position ${resumePosition}ms`);
} catch (err) {
this.eura.emit('playerError', this, err);
throw err;
}
}
saveAutoResumeState() {
if (!this.autoResumeState.enabled) return;
this.autoResumeState = {
...this.autoResumeState,
lastTrack: this.current,
lastPosition: this.position,
lastVolume: this.volume,
lastFilters: this.filters.getPayload ? this.filters.getPayload() : null,
lastUpdate: Date.now()
};
}
clearAutoResumeState() {
this.autoResumeState = {
enabled: this.autoResumeState.enabled,
lastTrack: null,
lastPosition: 0,
lastVolume: this.volume,
lastFilters: null,
lastUpdate: Date.now()
};
}
/**
*
* @param {this} player
* @returns
*/
async autoplay(player) {
if (!player) {
if (player == null) {
this.isAutoplay = false;
return this;
} else if (player == false) {
this.isAutoplay = false;
return this;
} else throw new Error("Missing argument. Quick Fix: player.autoplay(player)");
}
// Check if player is still connected before attempting autoplay
if (!this.connected) {
this.eura.emit("debug", this.guildId, "Player disconnected from voice, skipping autoplay");
return this;
}
this.isAutoplay = true;
if (player.previous) {
if (player.previous.info.sourceName === "youtube") {
try {
let data = `https://www.youtube.com/watch?v=${player.previous.info.identifier}&list=RD${player.previous.info.identifier}`;
let response = await this.eura.resolve({ query: data, source: "ytmsearch", requester: player.previous.info.requester });
if (this.node.rest.version === "v4") {
if (!response || !response.tracks || ["error", "empty"].includes(response.loadType)) return this.stop();
} else {
if (!response || !response.tracks || ["LOAD_FAILED", "NO_MATCHES"].includes(response.loadType)) return this.stop();
}
let track = response.tracks[Math.floor(Math.random() * Math.floor(response.tracks.length))];
this.queue.push(track);
this.play();
return this
} catch (e) {
return this.stop();
}
} else if (player.previous.info.sourceName === "soundcloud") {
try {
scAutoPlay(player.previous.info.uri).then(async (data) => {
// Check connection again before proceeding
if (!this.connected) {
this.eura.emit("debug", this.guildId, "Player disconnected during autoplay, aborting");
return;
}
let response = await this.eura.resolve({ query: data, source: "scsearch", requester: player.previous.info.requester });
if (this.node.rest.version === "v4") {
if (!response || !response.tracks || ["error", "empty"].includes(response.loadType)) return this.stop();
} else {
if (!response || !response.tracks || ["LOAD_FAILED", "NO_MATCHES"].includes(response.loadType)) return this.stop();
}
let track = response.tracks[Math.floor(Math.random() * Math.floor(response.tracks.length))];
this.queue.push(track);
this.play();
return this;
})
} catch (e) {
console.log(e);
return this.stop();
}
} else if (player.previous.info.sourceName === "spotify") {
try {
spAutoPlay(player.previous.info.identifier).then(async (data) => {
// Check connection again before proceeding
if (!this.connected) {
this.eura.emit("debug", this.guildId, "Player disconnected during autoplay, aborting");
return;
}
const response = await this.eura.resolve({ query: `https://open.spotify.com/track/${data}`, requester: player.previous.info.requester });
if (this.node.rest.version === "v4") {
if (!response || !response.tracks || ["error", "empty"].includes(response.loadType)) return this.stop();
} else {
if (!response || !response.tracks || ["LOAD_FAILED", "NO_MATCHES"].includes(response.loadType)) return this.stop();
}
let track = response.tracks[Math.floor(Math.random() * Math.floor(response.tracks.length))];
this.queue.push(track);
this.play();
return this;
})
} catch (e) {
console.log(e);
return this.stop();
}
}
} else return this;
}
async connect(options = this) {
if (this.eura.leastUsedNodes.length === 0) throw new Error("No nodes are available.");
if (this.connected) {
this.eura.emit("debug", `Player ${this.guildId} is already connected.`);
return this;
}
const { guildId, voiceChannel, textChannel } = options;
if (!guildId || !voiceChannel || !textChannel) {
throw new Error("Missing required options: guildId, voiceChannel, textChannel");
}
if (this.connection.connectionState === 'connecting') {
this.eura.emit("debug", `Player ${this.guildId} is already connecting.`);
return this;
}
this.connection.connectionState = 'connecting';
this.voiceChannel = voiceChannel;
this.textChannel = textChannel;
try {
this.eura.send({
op: 4,
d: {
guild_id: guildId,
channel_id: voiceChannel,
self_mute: this.mute,
self_deaf: this.deaf,
},
});
this.eura.emit("debug", `Player ${this.guildId} requested to connect to voice channel ${voiceChannel}.`);
} catch (error) {
this.connection.connectionState = 'disconnected';
throw error;
}
return this;
}
stop() {
this.position = 0;
this.playing = false;
this.timestamp = Date.now();
this.queueUpdate({
track: { encoded: null }
});
return this;
}
pause(toggle = true) {
this.queueUpdate({
paused: toggle
});
this.playing = !toggle;
this.paused = toggle;
this.timestamp = Date.now();
return this;
}
seek(position) {
this.queueUpdate({
position: position
});
this.position = position;
this.timestamp = Date.now();
return this;
}
setVolume(volume) {
if (volume < 0 || volume > 1000) throw new RangeError("Volume must be between 0 and 1000");
this.queueUpdate({
volume: volume
});
this.volume = volume;
return this;
}
setLoop(mode) {
if (!["none", "track", "queue"].includes(mode)) throw new RangeError("Loop mode must be 'none', 'track', or 'queue'");
this.loop = mode;
return this.loop;
}
setTextChannel(channel) {
this.textChannel = channel;
return this;
}
setVoiceChannel(channel, options) {
this.voiceChannel = channel;
this.connection.voiceChannel = channel;
if (options?.deaf !== undefined) this.deaf = options.deaf;
if (options?.mute !== undefined) this.mute = options.mute;
this.send({
guild_id: this.guildId,
channel_id: channel,
self_deaf: this.deaf,
self_mute: this.mute,
});
this.eura.emit("playerMove", this.voiceChannel, channel);
return this;
}
async disconnect() {
if (this.connection.connectionState === 'disconnected' || !this.voiceChannel) {
return this;
}
this.pause(true);
this.playing = false;
// Proactively send voice update to leave the channel
try {
this.eura.send({
op: 4,
d: {
guild_id: this.guildId,
channel_id: null,
self_mute: false,
self_deaf: false,
},
});
} catch (error) {
this.eura.emit("playerError", this, error);
}
this.connection.connectionState = 'disconnected';
this.connected = false;
this.voiceChannel = null;
// Use a small delay to ensure the event is emitted after state change
setTimeout(() => {
this.eura.emit("playerDisconnect", this);
}, 100);
return this;
}
async destroy(disconnect = true) {
if (disconnect) {
await this.disconnect();
}
// Clear any pending updates
if (this.updateTimeout) {
clearTimeout(this.updateTimeout);
this.updateTimeout = null;
}
this.updateQueue = [];
try {
await this.node.rest.destroyPlayer(this.guildId);
this.eura.emit("debug", `Player ${this.guildId} destroyed on node ${this.node.options.name}`);
} catch (error) {
// Log error but continue cleanup
this.eura.emit("playerError", this, new Error(`Failed to destroy player on node: ${error.message}`));
}
// Final cleanup
if (this.eura.players.has(this.guildId)) {
this.eura.players.delete(this.guildId);
}
// Clear bot activity status if setActivityStatus is enabled
if (this.eura.setActivityStatus && this.eura.client.user) {
this.eura.client.user.setActivity(null);
}
this.eura.emit("playerDestroy", this);
}
async handleEvent(payload) {
switch (payload.type) {
case "TrackStartEvent":
this.trackStart(this, this.current, payload);
break;
case "TrackEndEvent":
this.trackEnd(this, this.current, payload);
break;
case "TrackExceptionEvent":
this.trackError(this, this.current, payload);
break;
case "TrackStuckEvent":
this.trackStuck(this, this.current, payload);
break;
case "WebSocketClosedEvent":
this.socketClosed(this, payload);
break;
default:
this.eura.emit("debug", this.guildId, `Unknown event type: ${payload.type}`);
}
}
trackStart(player, track, payload) {
this.playing = true;
this.timestamp = Date.now();
this.eura.emit("trackStart", player, track, payload);
if (this.eura.setActivityStatus && this.eura.client.user) {
const activityText = this.eura.setActivityStatus.template
.replace('{title}', track.info.title)
.replace('{author}', track.info.author)
.replace('{duration}', this.formatDuration(track.info.length));
this.eura.client.user.setActivity(activityText, { type: ActivityType.Listening });
}
if (this.eura.euraSync && this.voiceChannel) {
const trackInfo = {
title: track.info.title,
author: track.info.author,
duration: this.formatDuration(track.info.length),
uri: track.info.uri,
source: track.info.sourceName
};
this.eura.euraSync.setVoiceStatus(this.voiceChannel, trackInfo, 'Track started playing')
.catch(error => {
this.eura.emit("debug", this.guildId, `EuraSync error: ${error.message}`);
});
}
}
trackEnd(player, track, payload) {
this.playing = false;
this.addToPreviousTrack(track);
this.eura.emit("trackEnd", player, track, payload);
if (payload.reason === "REPLACED") {
this.eura.emit("trackEnd", player, track, payload);
return;
}
// Check if player is still connected before attempting to play next track
if (!this.connected) {
this.eura.emit("debug", this.guildId, "Player disconnected from voice, skipping next track playback");
this.eura.emit("queueEnd", player, track, payload);
// Clear bot activity status if setActivityStatus is enabled
if (this.eura.setActivityStatus && this.eura.client.user) {
this.eura.client.user.setActivity(null);
}
return;
}
if (this.loop === "track" && payload.reason !== "STOPPED") {
this.queue.unshift(track);
this.play();
return;
}
if (this.loop === "queue" && payload.reason !== "STOPPED") {
this.queue.push(track);
this.play();
return;
}
if (this.queue.length > 0) {
this.play();
return;
}
if (this.isAutoplay) {
this.autoplay(player);
return;
}
if (this.eura.euraSync && this.voiceChannel) {
this.eura.euraSync.clearVoiceStatus(this.voiceChannel, 'Queue ended')
.catch(error => {
this.eura.emit("debug", this.guildId, `EuraSync error: ${error.message}`);
});
}
// Clear bot activity status if setActivityStatus is enabled
if (this.eura.setActivityStatus && this.eura.client.user) {
this.eura.client.user.setActivity(null);
}
this.eura.emit("queueEnd", player, track, payload);
}
trackError(player, track, payload) {
this.eura.emit("trackError", player, track, payload);
}
trackStuck(player, track, payload) {
this.eura.emit("trackStuck", player, track, payload);
}
socketClosed(player, payload) {
this.eura.emit("socketClosed", player, payload);
if (this.autoResumeState.enabled && this.eura.options.resume?.enabled && this.current) {
setTimeout(() => {
this.restart();
}, 1000);
}
}
send(data) {
this.eura.send(data);
}
set(key, value) {
this.data[key] = value;
return this;
}
get(key) {
return this.data[key];
}
clearData() {
this.data = {};
return this;
}
toJSON() {
return {
guildId: this.guildId,
textChannel: this.textChannel,
voiceChannel: this.voiceChannel,
volume: this.volume,
loop: this.loop,
playing: this.playing,
paused: this.paused,
connected: this.connected,
position: this.position,
timestamp: this.timestamp,
ping: this.ping,
current: this.current,
queue: this.queue,
previousTracks: this.previousTracks,
data: this.data,
autoResumeState: this.autoResumeState
};
}
static fromJSON(eura, node, data) {
const player = new Player(eura, node, {
guildId: data.guildId,
textChannel: data.textChannel,
voiceChannel: data.voiceChannel,
defaultVolume: data.volume,
loop: data.loop,
});
player.playing = data.playing;
player.paused = data.paused;
player.connected = data.connected;
player.position = data.position;
player.timestamp = data.timestamp;
player.ping = data.ping;
player.current = data.current;
// Properly reconstruct Queue instance
if (data.queue && Array.isArray(data.queue)) {
player.queue.length = 0; // Clear the default queue
player.queue.push(...data.queue); // Add all tracks from saved queue
}
player.previousTracks = data.previousTracks;
player.data = data.data;
player.autoResumeState = data.autoResumeState;
// Ensure autoResumeState.lastPosition is set to the saved position
if (player.autoResumeState && player.position > 0) {
player.autoResumeState.lastPosition = player.position;
}
return player;
}
async shuffleQueue() {
if (this.queue.length <= 1) return this;
const shuffled = [...this.queue];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
this.queue = shuffled;
this.eura.emit("queueShuffle", this);
return this;
}
moveQueueItem(from, to) {
if (from < 0 || from >= this.queue.length || to < 0 || to >= this.queue.length) return this;
const item = this.queue.splice(from, 1)[0];
this.queue.splice(to, 0, item);
this.eura.emit("queueMove", this, from, to);
return this;
}
removeQueueItem(index) {
if (index < 0 || index >= this.queue.length) return this;
const removed = this.queue.splice(index, 1)[0];
this.eura.emit("queueRemove", this, removed, index);
return this;
}
formatDuration(ms) {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
}
/**
* Set SponsorBlock categories for automatic skipping
* @param {Array<string>} categories Array of category names to skip
* @returns {Promise<boolean>} Success status
*/
async setSponsorBlockCategories(categories = ['sponsor', 'selfpromo', 'interaction']) {
try {
if (!this.node.sessionId) {
throw new Error('Node session not available');
}
await this.node.rest.makeRequest(
'PUT',
`/v4/sessions/${this.node.sessionId}/players/${this.guildId}/sponsorblock/categories`,
categories
);
this.sponsorBlock.categories = categories;
this.sponsorBlock.enabled = categories.length > 0;
this.eura.emit("debug", this.guildId, `SponsorBlock categories updated: ${categories.join(', ')}`);
return true;
} catch (error) {
this.eura.emit("debug", this.guildId, `Failed to set SponsorBlock categories: ${error.message}`);
return false;
}
}
/**
* Get current SponsorBlock categories
* @returns {Promise<Array<string>|null>} Current categories or null on error
*/
async getSponsorBlockCategories() {
try {
if (!this.node.sessionId) {
throw new Error('Node session not available');
}
const response = await this.node.rest.makeRequest(
'GET',
`/v4/sessions/${this.node.sessionId}/players/${this.guildId}/sponsorblock/categories`
);
return response || [];
} catch (error) {
this.eura.emit("debug", this.guildId, `Failed to get SponsorBlock categories: ${error.message}`);
return null;
}
}
/**
* Clear SponsorBlock categories (disable automatic skipping)
* @returns {Promise<boolean>} Success status
*/
async clearSponsorBlockCategories() {
try {
if (!this.node.sessionId) {
throw new Error('Node session not available');
}
await this.node.rest.makeRequest(
'DELETE',
`/v4/sessions/${this.node.sessionId}/players/${this.guildId}/sponsorblock/categories`
);
this.sponsorBlock.categories = [];
this.sponsorBlock.enabled = false;
this.eura.emit("debug", this.guildId, "SponsorBlock categories cleared");
return true;
} catch (error) {
this.eura.emit("debug", this.guildId, `Failed to clear SponsorBlock categories: ${error.message}`);
return false;
}
}
/**
* Get loaded SponsorBlock segments for current track
* @returns {Array} Array of segments
*/
getSponsorBlockSegments() {
return this.sponsorBlock.segments;
}
/**
* Get loaded chapters for current track
* @returns {Array} Array of chapters
*/
getChapters() {
return this.sponsorBlock.chapters;
}
/**
* Get current chapter based on playback position
* @param {number} position Current position in milliseconds
* @returns {Object|null} Current chapter or null
*/
getCurrentChapter(position = this.position) {
if (!this.sponsorBlock.chapters.length) return null;
for (const chapter of this.sponsorBlock.chapters) {
if (position >= chapter.start && position < chapter.end) {
return chapter;
}
}
return null;
}
}
module.exports = { Player };