Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -63,21 +69,28 @@ client.login();
<summary>Module Augmentation - TypeScript</summary>

```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;
}
Expand All @@ -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
Expand All @@ -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);
}
}
```
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/Constants/Symbols.ts
Original file line number Diff line number Diff line change
@@ -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");

Expand Down
8 changes: 7 additions & 1 deletion src/Queue/Queue.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -30,6 +30,8 @@ export class Queue<Context extends Record<string, unknown> = 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");

Expand All @@ -55,6 +57,7 @@ export class Queue<Context extends Record<string, unknown> = QueueContext> {
voice: immutable,
filters: immutable,
player: { ...immutable, enumerable: false },
[LastTrackSymbol]: { configurable: false, enumerable: false },
} satisfies { [k in keyof Queue]?: PropertyDescriptor });
}

Expand Down Expand Up @@ -274,6 +277,7 @@ export class Queue<Context extends Record<string, unknown> = 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({
Expand Down Expand Up @@ -318,6 +322,7 @@ export class Queue<Context extends Record<string, unknown> = 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();
}
Expand Down Expand Up @@ -359,6 +364,7 @@ export class Queue<Context extends Record<string, unknown> = QueueContext> {
}

async stop() {
this[LastTrackSymbol] ??= this.track;
return this.#update({ track: { encoded: null } });
}

Expand Down
30 changes: 23 additions & 7 deletions src/Queue/QueueManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { setImmediate } from "node:timers/promises";
import { EventType, TrackEndReason } from "../Typings";
import {
LastTrackSymbol,
LookupSymbol,
OnEventUpdateSymbol,
OnPingUpdateSymbol,
Expand All @@ -20,6 +21,7 @@ import type {
TrackExceptionEventPayload,
TrackStuckEventPayload,
QueueContext,
APITrack,
} from "../Typings";
import type { Player } from "../Main";

Expand Down Expand Up @@ -193,20 +195,33 @@ export class QueueManager<Context extends Record<string, unknown> = 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;
Expand All @@ -217,10 +232,10 @@ export class QueueManager<Context extends Record<string, unknown> = 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()!);
Expand All @@ -233,7 +248,7 @@ export class QueueManager<Context extends Record<string, unknown> = 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 });
Expand Down Expand Up @@ -265,6 +280,7 @@ export class QueueManager<Context extends Record<string, unknown> = QueueContext
return this.#onTrackStuck(queue, payload);

case EventType.WebSocketClosed:
cache.state.connected = false;
return this.player.voices[OnVoiceCloseSymbol](queue.voice, payload);
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/Typings/Main/Player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}

/**
Expand Down
5 changes: 0 additions & 5 deletions src/Voice/VoiceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,10 +299,6 @@ export class VoiceManager implements Partial<Map<string, VoiceState>> {
}

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:
Expand All @@ -311,7 +307,6 @@ export class VoiceManager implements Partial<Map<string, VoiceState>> {
break;
}
this.player.emit("voiceClose", voice, payload.code, payload.reason, payload.byRemote);

if (!voice.reconnecting) return;
try {
await voice.connect();
Expand Down
Loading