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