magmastream
Version:
A user-friendly Lavalink client designed for NodeJS.
754 lines (753 loc) • 30.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RedisQueue = void 0;
const Enums_1 = require("../structures/Enums");
const Utils_1 = require("../structures/Utils");
const MagmastreamError_1 = require("../structures/MagmastreamError");
/**
* The player's queue, the `current` property is the currently playing track, think of the rest as the up-coming tracks.
*/
class RedisQueue {
guildId;
manager;
/**
* The prefix for the Redis keys.
*/
redisPrefix;
/**
* The Redis instance.
*/
redis;
/**
* Whether the queue has been destroyed.
*/
destroyed = false;
/**
* Constructs a new RedisQueue.
* @param guildId The guild ID.
* @param manager The Manager instance.
*/
constructor(guildId, manager) {
this.guildId = guildId;
this.manager = manager;
this.redis = manager.redis;
this.redisPrefix = Utils_1.PlayerUtils.getRedisKey();
}
// #region Public
/**
* Adds a track or tracks to the queue.
* @param track The track or tracks to add. Can be a single `Track` or an array of `Track`s.
* @param [offset=null] The position to add the track(s) at. If not provided, the track(s) will be added at the end of the queue.
*/
async add(track, offset) {
try {
const isArray = Array.isArray(track);
const tracks = isArray ? track : [track];
// Serialize tracks
const serialized = tracks.map((t) => this.serialize(t));
const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null;
// Set current track if none exists
if (!(await this.getCurrent())) {
const current = serialized.shift();
if (current) {
await this.setCurrent(this.deserialize(current));
}
}
// Insert at offset or append
try {
if (typeof offset === "number" && !isNaN(offset)) {
const queue = await this.redis.lrange(this.queueKey, 0, -1);
queue.splice(offset, 0, ...serialized);
await this.redis.del(this.queueKey);
if (queue.length > 0) {
await this.redis.rpush(this.queueKey, ...queue);
}
}
else if (serialized.length > 0) {
await this.redis.rpush(this.queueKey, ...serialized);
}
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to add tracks to Redis queue for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[REDISQUEUE] Added ${tracks.length} track(s) to queue`);
// Autoplay logic
if (this.manager.players.has(this.guildId) && this.manager.players.get(this.guildId).isAutoplay) {
if (!Array.isArray(track)) {
if (track.isAutoplay) {
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), {
changeType: Enums_1.PlayerStateEventTypes.QueueChange,
details: {
type: "queue",
action: "autoPlayAdd",
tracks: [track],
},
});
return;
}
}
}
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), {
changeType: Enums_1.PlayerStateEventTypes.QueueChange,
details: {
type: "queue",
action: "add",
tracks,
},
});
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Unexpected error in add() for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Adds a track or tracks to the previous tracks.
* @param track The track or tracks to add.
*/
async addPrevious(track) {
try {
const tracks = Array.isArray(track) ? track : [track];
if (!tracks.length) {
throw new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `No tracks provided for addPrevious in guild ${this.guildId}`,
});
}
const serialized = tracks.map(this.serialize);
try {
// Push newest to TAIL
await this.redis.rpush(this.previousKey, ...serialized);
// Keep only the most recent maxPreviousTracks (trim from HEAD)
const max = this.manager.options.maxPreviousTracks;
await this.redis.ltrim(this.previousKey, -max, -1);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to add previous tracks to Redis for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Unexpected error in addPrevious() for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Clears the queue.
*/
async clear() {
const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null;
try {
await this.redis.del(this.queueKey);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to clear queue for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), {
changeType: Enums_1.PlayerStateEventTypes.QueueChange,
details: {
type: "queue",
action: "clear",
tracks: [],
},
});
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[REDISQUEUE] Cleared the queue for: ${this.guildId}`);
}
/**
* Clears the previous tracks.
*/
async clearPrevious() {
try {
await this.redis.del(this.previousKey);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to clear previous tracks for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Removes the first track from the queue.
*/
async dequeue() {
try {
const raw = await this.redis.lpop(this.queueKey);
return raw ? this.deserialize(raw) : undefined;
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to dequeue track for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Destroys the queue and releases all resources.
* After calling this method, the queue must not be used again.
*/
async destroy() {
if (this.destroyed)
return;
this.destroyed = true;
try {
await this.redis.del(this.queueKey, this.previousKey, this.currentKey);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to destroy RedisQueue for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* @returns The total duration of the queue in milliseconds.
* This includes the duration of the currently playing track.
*/
async duration() {
try {
const tracks = await this.redis.lrange(this.queueKey, 0, -1);
const currentDuration = (await this.getCurrent())?.duration || 0;
const total = tracks.reduce((acc, raw) => {
try {
const parsed = this.deserialize(raw);
return acc + (parsed.duration || 0);
}
catch (err) {
// Skip invalid tracks but log
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[REDISQUEUE] Skipping invalid track during duration calculation for guild ${this.guildId}: ${err.message}`);
return acc;
}
}, currentDuration);
return total;
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to calculate total queue duration for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Adds a track to the front of the queue.
* @param track The track or tracks to add.
*/
async enqueueFront(track) {
try {
const serialized = Array.isArray(track) ? track.map(this.serialize) : [this.serialize(track)];
// Redis: LPUSH adds to front, reverse to maintain order if multiple tracks
await this.redis.lpush(this.queueKey, ...serialized.reverse());
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to enqueue track to front for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Whether all tracks in the queue match the specified condition.
* @param callback The condition to match.
* @returns Whether all tracks in the queue match the specified condition.
*/
async everyAsync(callback) {
const tracks = await this.getTracks();
return tracks.every(callback);
}
/**
* Filters the tracks in the queue.
* @param callback The condition to match.
* @returns The tracks that match the condition.
*/
async filterAsync(callback) {
const tracks = await this.getTracks();
return tracks.filter(callback);
}
/**
* Finds the first track in the queue that matches the specified condition.
* @param callback The condition to match.
* @returns The first track that matches the condition.
*/
async findAsync(callback) {
const tracks = await this.getTracks();
return tracks.find(callback);
}
/**
* @returns The current track.
*/
async getCurrent() {
try {
const raw = await this.redis.get(this.currentKey);
return raw ? this.deserialize(raw) : null;
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to get current track for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* @returns The previous tracks.
*/
async getPrevious() {
try {
const raw = await this.redis.lrange(this.previousKey, 0, -1);
return raw.map(this.deserialize);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to get previous tracks for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* @returns The tracks in the queue from the start to the end.
*/
async getSlice(start = 0, end = -1) {
try {
const raw = await this.redis.lrange(this.queueKey, start, end === -1 ? -1 : end - 1);
return raw.map(this.deserialize);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to get slice of queue for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* @returns The tracks in the queue.
*/
async getTracks() {
try {
const raw = await this.redis.lrange(this.queueKey, 0, -1);
return raw.map(this.deserialize);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to get tracks for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Maps the tracks in the queue.
* @returns The tracks in the queue after the specified index.
*/
async mapAsync(callback) {
const tracks = await this.getTracks(); // same as lrange + deserialize
return tracks.map(callback);
}
/**
* Modifies the queue at the specified index.
* @param start The start index.
* @param deleteCount The number of tracks to delete.
* @param items The tracks to insert.
* @returns The removed tracks.
*/
async modifyAt(start, deleteCount = 0, ...items) {
try {
const queue = await this.redis.lrange(this.queueKey, 0, -1);
const removed = queue.splice(start, deleteCount, ...items.map(this.serialize));
await this.redis.del(this.queueKey);
if (queue.length > 0) {
await this.redis.rpush(this.queueKey, ...queue);
}
return removed.map(this.deserialize);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to modify queue at index ${start} for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Removes the newest track.
* @returns The newest track.
*/
async popPrevious() {
try {
// Pop the newest track from the TAIL
const raw = await this.redis.rpop(this.previousKey);
return raw ? this.deserialize(raw) : null;
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to pop previous track for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
async remove(startOrPos = 0, end) {
try {
const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null;
const queue = await this.redis.lrange(this.queueKey, 0, -1);
let removed = [];
if (typeof end === "number") {
if (startOrPos >= end || startOrPos >= queue.length) {
throw new RangeError("Invalid range.");
}
removed = queue.slice(startOrPos, end);
queue.splice(startOrPos, end - startOrPos);
}
else {
removed = queue.splice(startOrPos, 1);
}
await this.redis.del(this.queueKey);
if (queue.length > 0) {
await this.redis.rpush(this.queueKey, ...queue);
}
const deserialized = removed.map(this.deserialize);
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[REDISQUEUE] Removed ${removed.length} track(s) from position ${startOrPos}${end ? ` to ${end}` : ""}`);
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), {
changeType: Enums_1.PlayerStateEventTypes.QueueChange,
details: {
type: "queue",
action: "remove",
tracks: deserialized,
},
});
return deserialized;
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to remove track for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Shuffles the queue round-robin style.
*/
async roundRobinShuffle() {
try {
const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null;
const rawTracks = await this.redis.lrange(this.queueKey, 0, -1);
const deserialized = rawTracks.map(this.deserialize);
const userMap = new Map();
for (const track of deserialized) {
const userId = track.requester.id.toString();
if (!userMap.has(userId))
userMap.set(userId, []);
userMap.get(userId).push(track);
}
// Shuffle each user's tracks
for (const tracks of userMap.values()) {
for (let i = tracks.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[tracks[i], tracks[j]] = [tracks[j], tracks[i]];
}
}
const users = [...userMap.keys()];
const queues = users.map((id) => userMap.get(id));
const shuffledQueue = [];
while (queues.some((q) => q.length > 0)) {
for (const q of queues) {
const track = q.shift();
if (track)
shuffledQueue.push(track);
}
}
await this.redis.del(this.queueKey);
await this.redis.rpush(this.queueKey, ...shuffledQueue.map(this.serialize));
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), {
changeType: Enums_1.PlayerStateEventTypes.QueueChange,
details: {
type: "queue",
action: "roundRobin",
},
});
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[REDISQUEUE] roundRobinShuffled the queue for: ${this.guildId}`);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to roundRobinShuffle the queue for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Sets the current track.
* @param track The track to set.
*/
async setCurrent(track) {
try {
if (track) {
await this.redis.set(this.currentKey, this.serialize(track));
}
else {
await this.redis.del(this.currentKey);
}
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to setCurrent the queue for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Sets the previous track(s).
* @param track The track to set.
*/
async setPrevious(track) {
try {
const tracks = Array.isArray(track) ? track : [track];
if (!tracks.length)
return;
await this.redis
.multi()
.del(this.previousKey)
.rpush(this.previousKey, ...tracks.map(this.serialize))
.exec();
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to setPrevious the queue for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* Shuffles the queue.
*/
async shuffle() {
try {
const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null;
const queue = await this.redis.lrange(this.queueKey, 0, -1);
for (let i = queue.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[queue[i], queue[j]] = [queue[j], queue[i]];
}
await this.redis.del(this.queueKey);
if (queue.length > 0) {
await this.redis.rpush(this.queueKey, ...queue);
}
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), {
changeType: Enums_1.PlayerStateEventTypes.QueueChange,
details: {
type: "queue",
action: "shuffle",
},
});
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[REDISQUEUE] Shuffled the queue for: ${this.guildId}`);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to shuffle the queue for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* @returns The size of the queue.
*/
async size() {
try {
return await this.redis.llen(this.queueKey);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to get the size of the queue for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
/**
* @returns Whether any tracks in the queue match the specified condition.
*/
async someAsync(callback) {
const tracks = await this.getTracks();
return tracks.some(callback);
}
/**
* @returns The total size of tracks in the queue including the current track.
*/
async totalSize() {
const size = await this.size();
return (await this.getCurrent()) ? size + 1 : size;
}
/**
* Shuffles the queue, but keeps the tracks of the same user together.
*/
async userBlockShuffle() {
try {
const oldPlayer = this.manager.players.get(this.guildId) ? { ...this.manager.players.get(this.guildId) } : null;
const rawTracks = await this.redis.lrange(this.queueKey, 0, -1);
const deserialized = rawTracks.map(this.deserialize);
const userMap = new Map();
for (const track of deserialized) {
const userId = track.requester.id.toString();
if (!userMap.has(userId))
userMap.set(userId, []);
userMap.get(userId).push(track);
}
const shuffledQueue = [];
while (shuffledQueue.length < deserialized.length) {
for (const [, tracks] of userMap) {
const track = tracks.shift();
if (track)
shuffledQueue.push(track);
}
}
await this.redis.del(this.queueKey);
await this.redis.rpush(this.queueKey, ...shuffledQueue.map(this.serialize));
this.manager.emit(Enums_1.ManagerEventTypes.PlayerStateUpdate, oldPlayer, this.manager.players.get(this.guildId), {
changeType: Enums_1.PlayerStateEventTypes.QueueChange,
details: {
type: "queue",
action: "userBlock",
},
});
this.manager.emit(Enums_1.ManagerEventTypes.Debug, `[REDISQUEUE] userBlockShuffled the queue for: ${this.guildId}`);
}
catch (err) {
const error = err instanceof MagmastreamError_1.MagmaStreamError
? err
: new MagmastreamError_1.MagmaStreamError({
code: Enums_1.MagmaStreamErrorCode.QUEUE_REDIS_ERROR,
message: `Failed to userBlockShuffle the queue for guild ${this.guildId}: ${err.message}`,
cause: err,
});
console.error(error);
}
}
// #endregion Public
// #region Private
/**
* @returns The current key.
*/
get currentKey() {
return `${this.redisPrefix}queue:${this.guildId}:current`;
}
/**
* Deserializes a track from a string.
*/
deserialize(data) {
const track = JSON.parse(data);
return Utils_1.TrackUtils.revive(track);
}
/**
* @returns The previous key.
*/
get previousKey() {
return `${this.redisPrefix}queue:${this.guildId}:previous`;
}
/**
* @returns The queue key.
*/
get queueKey() {
return `${this.redisPrefix}queue:${this.guildId}:tracks`;
}
/**
* Helper to serialize/deserialize Track
*/
serialize(track) {
// return JSONUtils.serializeTrack(track);
return Utils_1.JSONUtils.safe(track, 2);
}
}
exports.RedisQueue = RedisQueue;