diff --git a/README.md b/README.md index 96dc347..65c5345 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,13 @@ import { Player } from "discolink"; const client = new Client(...); const player = new Player({ - nodes: [], // add your nodes + nodes: [ // add your nodes + { + name: "local", + origin: "http://localhost:2333", + password: "youshallnotpass" + } + ], async forwardVoiceUpdate(guildId, payload) { // send the given payload to your gateway connection client.guilds.cache.get(guildId).shard.send(payload); @@ -63,21 +69,28 @@ client.login(); Module Augmentation - TypeScript ```ts +/** + * fields defined here appear wherever they're concerned + */ declare module "discolink" { + // appears on queue, related options, etc interface QueueContext { textId: string; } + // appears on track, related options, etc interface CommonUserData { id: string; username: string; displayName: string; } + // appears on track, playlist, etc interface CommonPluginInfo { save_uri?: string; } + // appears throughout filter management interface CommonPluginFilters { custom: string; } @@ -93,7 +106,8 @@ declare module "discolink" { import { PlayerPlugin, type Player } from "discolink"; export class CustomPlugin extends PlayerPlugin<{ - event: [a: string, b: object]; + // define events you want to emit on player + eventName: [s: number, d: object]; }> { readonly name = "custom"; // 'readonly' is mandatory #player!: Player; // optional, just for convenience @@ -103,9 +117,13 @@ export class CustomPlugin extends PlayerPlugin<{ player.on("nodeDispatch", this.#onDispatch); } + transform(...args: unknown[]): [s: number, d: object] {} + #onDispatch(this: Player, ...args: unknown[]) { // work with data - // e.g. transform -> name event -> dispatch + // e.g. transform -> rename event -> dispatch + const transformed = this.transform(...args); + this.emit("eventName", ...transformed); } } ``` @@ -114,13 +132,18 @@ export class CustomPlugin extends PlayerPlugin<{ ### Additional Notes -- Handle track end reasons other than [`cleanup`](https://execaman.github.io/discolink/enums/Typings.TrackEndReason.html#cleanup) and [`finished`](https://execaman.github.io/discolink/enums/Typings.TrackEndReason.html#finished) -- Handle voice states carefully, e.g. [`reconnecting`](https://execaman.github.io/discolink/classes/Voice.VoiceState.html#reconnecting), [`changingNode`](https://execaman.github.io/discolink/classes/Voice.VoiceState.html#changingnode), etc. -- Handle queue destruction/relocation, e.g. guild/channel delete, node close/disconnect, etc. +- Destroy queues when necessary, e.g. events like guild/channel delete, etc. + +- Check voice states like [`reconnecting`](https://execaman.github.io/discolink/classes/Voice.VoiceState.html#reconnecting) and [`changingNode`](https://execaman.github.io/discolink/classes/Voice.VoiceState.html#changingnode) before taking action + +- Handle track end reasons other than [`cleanup`](https://execaman.github.io/discolink/enums/Typings.TrackEndReason.html#cleanup) and [`finished`](https://execaman.github.io/discolink/enums/Typings.TrackEndReason.html#finished) - especially [`replaced`](https://execaman.github.io/discolink/enums/Typings.TrackEndReason.html#replaced) + +> [!NOTE] +> [`replaced`](https://execaman.github.io/discolink/enums/Typings.TrackEndReason.html#replaced) is an edge case where we cannot reliably determine the exact track object in queue that ended. The queue implements a workaround for this and provides a [`inQueue`](http://localhost:3000/docs/interfaces/Typings.PlayerEventMap.html#trackstart) (think cache hit/miss) boolean in track events ### Session Resumption -Resuming a node's session after your bot restarts requires careful planning, depending on scale. As such, the lib has no plans to provide built-in support for it. Disable both [`autoSync`](https://execaman.github.io/discolink/interfaces/Typings.PlayerOptions.html#autosync) and [`relocateQueues`](https://execaman.github.io/discolink/interfaces/Typings.PlayerOptions.html#relocatequeues) for predictable behavior if you're implementing this feature. +Resuming a node's session after your bot restarts requires careful planning, depending on scale. As such, the lib has no plans to provide built-in support for it. Disable either or both of [`autoSync`](https://execaman.github.io/discolink/interfaces/Typings.PlayerOptions.html#autosync) and [`relocateQueues`](https://execaman.github.io/discolink/interfaces/Typings.PlayerOptions.html#relocatequeues) options for predictable behavior if you're implementing this feature. ## 🤖 Bots in Production diff --git a/src/Constants/Symbols.ts b/src/Constants/Symbols.ts index 94d03aa..2ef44b3 100644 --- a/src/Constants/Symbols.ts +++ b/src/Constants/Symbols.ts @@ -1,6 +1,8 @@ export const LookupSymbol = Symbol("lookup"); export const UpdateSymbol = Symbol("update"); +export const LastTrackSymbol = Symbol("lastTrack"); + export const OnPingUpdateSymbol = Symbol("onPingUpdate"); export const OnVoiceCloseSymbol = Symbol("onVoiceClose"); diff --git a/src/Queue/Queue.ts b/src/Queue/Queue.ts index efd236f..ac83077 100644 --- a/src/Queue/Queue.ts +++ b/src/Queue/Queue.ts @@ -1,5 +1,5 @@ import { Severity } from "../Typings"; -import { LookupSymbol, UpdateSymbol } from "../Constants/Symbols"; +import { LastTrackSymbol, LookupSymbol, UpdateSymbol } from "../Constants/Symbols"; import { formatDuration, isArray, isNumber } from "../Functions"; import { Playlist, Track } from "../index"; import { VoiceState } from "../Voice"; @@ -30,6 +30,8 @@ export class Queue = QueueContext> { readonly filters: FilterManager; readonly player: Player; + [LastTrackSymbol]: Track | null = null; + constructor(player: Player, guildId: string, context?: Context) { if (player.queues.has(guildId)) throw new Error("An identical queue already exists"); @@ -55,6 +57,7 @@ export class Queue = QueueContext> { voice: immutable, filters: immutable, player: { ...immutable, enumerable: false }, + [LastTrackSymbol]: { configurable: false, enumerable: false }, } satisfies { [k in keyof Queue]?: PropertyDescriptor }); } @@ -274,6 +277,7 @@ export class Queue = QueueContext> { if (!isNumber(index, "integer")) throw this.#error("Index must be a integer"); const track = index < 0 ? this.#previousTracks[this.#previousTracks.length + index] : this.#tracks[index]; if (!track) throw this.#error("Specified index is out of range"); + this[LastTrackSymbol] = this.track; if (index < 0) this.#tracks.unshift(...this.#previousTracks.splice(index)); else this.#previousTracks.push(...this.#tracks.splice(0, index)); await this.#update({ @@ -318,6 +322,7 @@ export class Queue = QueueContext> { if (related.length > 0) return this.jump(this.length - related.length); } if (!this.finished) { + this[LastTrackSymbol] = this.track; this.#previousTracks.push(this.#tracks.shift()!); await this.stop(); } @@ -359,6 +364,7 @@ export class Queue = QueueContext> { } async stop() { + this[LastTrackSymbol] ??= this.track; return this.#update({ track: { encoded: null } }); } diff --git a/src/Queue/QueueManager.ts b/src/Queue/QueueManager.ts index 75d1234..f5b44e3 100644 --- a/src/Queue/QueueManager.ts +++ b/src/Queue/QueueManager.ts @@ -1,6 +1,7 @@ import { setImmediate } from "node:timers/promises"; import { EventType, TrackEndReason } from "../Typings"; import { + LastTrackSymbol, LookupSymbol, OnEventUpdateSymbol, OnPingUpdateSymbol, @@ -20,6 +21,7 @@ import type { TrackExceptionEventPayload, TrackStuckEventPayload, QueueContext, + APITrack, } from "../Typings"; import type { Player } from "../Main"; @@ -193,20 +195,33 @@ export class QueueManager = QueueContext this.#relocations.delete(node); } + #getLocalTrack(queue: Queue, track: APITrack, replaced = false) { + const trackId = track.info.identifier; + const lastTrack = queue[LastTrackSymbol]; + if (lastTrack !== null) queue[LastTrackSymbol] = null; + const _track = + !replaced && queue.track?.id === trackId ? queue.track + : lastTrack?.id === trackId ? lastTrack + : null; + return [_track ?? new Track(track), _track !== null] as const; + } + async #onTrackStart(queue: Queue, payload: TrackStartEventPayload) { - this.player.emit("trackStart", queue, new Track(payload.track)); + this.player.emit("trackStart", queue, ...this.#getLocalTrack(queue, payload.track)); } async #onTrackError(queue: Queue, payload: TrackExceptionEventPayload) { - this.player.emit("trackError", queue, new Track(payload.track), payload.exception); + const [track, inQueue] = this.#getLocalTrack(queue, payload.track); + this.player.emit("trackError", queue, track, payload.exception, inQueue); } async #onTrackStuck(queue: Queue, payload: TrackStuckEventPayload) { - this.player.emit("trackStuck", queue, new Track(payload.track), payload.thresholdMs); + const [track, inQueue] = this.#getLocalTrack(queue, payload.track); + this.player.emit("trackStuck", queue, track, payload.thresholdMs, inQueue); } async #onTrackEnd(queue: Queue, payload: TrackEndEventPayload) { - const track = new Track(payload.track); + const [track, inQueue] = this.#getLocalTrack(queue, payload.track, payload.reason === TrackEndReason.Replaced); switch (payload.reason) { case TrackEndReason.Cleanup: if (track.id !== queue.track?.id) break; @@ -217,10 +232,10 @@ export class QueueManager = QueueContext if (queue.repeatMode !== "track") queue.previousTracks.push(queue.tracks.shift()!); break; default: - this.player.emit("trackFinish", queue, track, payload.reason); + this.player.emit("trackFinish", queue, track, payload.reason, inQueue); return; } - this.player.emit("trackFinish", queue, track, payload.reason); + this.player.emit("trackFinish", queue, track, payload.reason, inQueue); try { if (queue.finished) { if (queue.hasPrevious && queue.repeatMode === "queue") queue.tracks.push(queue.previousTracks.shift()!); @@ -233,7 +248,7 @@ export class QueueManager = QueueContext } } - [OnStateUpdateSymbol](payload: PlayerUpdatePayload) { + async [OnStateUpdateSymbol](payload: PlayerUpdatePayload) { const queue = this.#queues.get(payload.guildId); if (!queue) return; this[UpdateSymbol](payload.guildId, { state: payload.state }); @@ -265,6 +280,7 @@ export class QueueManager = QueueContext return this.#onTrackStuck(queue, payload); case EventType.WebSocketClosed: + cache.state.connected = false; return this.player.voices[OnVoiceCloseSymbol](queue.voice, payload); } } diff --git a/src/Typings/Main/Player.ts b/src/Typings/Main/Player.ts index 0a25918..8137d83 100644 --- a/src/Typings/Main/Player.ts +++ b/src/Typings/Main/Player.ts @@ -30,10 +30,10 @@ export interface PlayerEventMap { queueFinish: [queue: Queue]; queueDestroy: [queue: Queue, reason: string]; - trackStart: [queue: Queue, track: Track]; - trackStuck: [queue: Queue, track: Track, thresholdMs: number]; - trackError: [queue: Queue, track: Track, exception: Exception]; - trackFinish: [queue: Queue, track: Track, reason: TrackEndReason]; + trackStart: [queue: Queue, track: Track, inQueue: boolean]; + trackStuck: [queue: Queue, track: Track, thresholdMs: number, inQueue: boolean]; + trackError: [queue: Queue, track: Track, exception: Exception, inQueue: boolean]; + trackFinish: [queue: Queue, track: Track, reason: TrackEndReason, inQueue: boolean]; } /** diff --git a/src/Voice/VoiceManager.ts b/src/Voice/VoiceManager.ts index 9f760a1..990e045 100644 --- a/src/Voice/VoiceManager.ts +++ b/src/Voice/VoiceManager.ts @@ -299,10 +299,6 @@ export class VoiceManager implements Partial> { } async [OnVoiceCloseSymbol](voice: Queue["voice"], payload: WebSocketClosedEventPayload) { - const player = this.player.queues[LookupSymbol](payload.guildId); - - if (player !== undefined) player.state.connected = false; - switch (payload.code) { case VoiceCloseCodes.AuthenticationFailed: case VoiceCloseCodes.ServerNotFound: @@ -311,7 +307,6 @@ export class VoiceManager implements Partial> { break; } this.player.emit("voiceClose", voice, payload.code, payload.reason, payload.byRemote); - if (!voice.reconnecting) return; try { await voice.connect();