ryanlink 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +37 -0
- package/README.md +455 -0
- package/dist/index.d.mts +1335 -0
- package/dist/index.d.ts +1335 -0
- package/dist/index.js +4694 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +4604 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +82 -0
- package/src/audio/AudioFilters.ts +316 -0
- package/src/audio/AudioQueue.ts +782 -0
- package/src/audio/AudioTrack.ts +242 -0
- package/src/audio/QueueController.ts +252 -0
- package/src/audio/TrackCollection.ts +138 -0
- package/src/audio/index.ts +9 -0
- package/src/config/defaults.ts +223 -0
- package/src/config/endpoints.ts +99 -0
- package/src/config/index.ts +9 -0
- package/src/config/patterns.ts +55 -0
- package/src/config/presets.ts +400 -0
- package/src/config/symbols.ts +31 -0
- package/src/core/PluginSystem.ts +50 -0
- package/src/core/RyanlinkPlayer.ts +403 -0
- package/src/core/index.ts +6 -0
- package/src/extensions/AutoplayExtension.ts +283 -0
- package/src/extensions/FairPlayExtension.ts +154 -0
- package/src/extensions/LyricsExtension.ts +187 -0
- package/src/extensions/PersistenceExtension.ts +182 -0
- package/src/extensions/SponsorBlockExtension.ts +81 -0
- package/src/extensions/index.ts +9 -0
- package/src/index.ts +19 -0
- package/src/lavalink/ConnectionPool.ts +326 -0
- package/src/lavalink/HttpClient.ts +316 -0
- package/src/lavalink/LavalinkConnection.ts +409 -0
- package/src/lavalink/index.ts +7 -0
- package/src/metadata.ts +88 -0
- package/src/types/api/Rest.ts +949 -0
- package/src/types/api/Websocket.ts +463 -0
- package/src/types/api/index.ts +6 -0
- package/src/types/audio/FilterManager.ts +29 -0
- package/src/types/audio/Queue.ts +4 -0
- package/src/types/audio/QueueManager.ts +30 -0
- package/src/types/audio/index.ts +7 -0
- package/src/types/common.ts +63 -0
- package/src/types/core/Player.ts +322 -0
- package/src/types/core/index.ts +5 -0
- package/src/types/index.ts +6 -0
- package/src/types/lavalink/Node.ts +173 -0
- package/src/types/lavalink/NodeManager.ts +34 -0
- package/src/types/lavalink/REST.ts +144 -0
- package/src/types/lavalink/index.ts +32 -0
- package/src/types/voice/VoiceManager.ts +176 -0
- package/src/types/voice/index.ts +5 -0
- package/src/utils/helpers.ts +169 -0
- package/src/utils/index.ts +6 -0
- package/src/utils/validators.ts +184 -0
- package/src/voice/RegionSelector.ts +184 -0
- package/src/voice/VoiceConnection.ts +451 -0
- package/src/voice/VoiceSession.ts +297 -0
- package/src/voice/index.ts +7 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { formatDuration, isNumber, isRecord, isString } from "../utils";
|
|
2
|
+
import type { APITrack, CommonPluginInfo, CommonUserData, JsonObject } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents a music track with metadata and playback information
|
|
6
|
+
*/
|
|
7
|
+
export class Track<UserData extends JsonObject = CommonUserData, PluginInfo extends JsonObject = CommonPluginInfo> {
|
|
8
|
+
/**
|
|
9
|
+
* Unique identifier of the track
|
|
10
|
+
*/
|
|
11
|
+
id: string;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Title of the track
|
|
15
|
+
*/
|
|
16
|
+
title = "Unknown Track";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Author/artist of the track
|
|
20
|
+
*/
|
|
21
|
+
author = "Unknown Author";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the track is a live stream
|
|
25
|
+
*/
|
|
26
|
+
isLive = false;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether the track is seekable
|
|
30
|
+
*/
|
|
31
|
+
isSeekable = false;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Duration of the track in milliseconds
|
|
35
|
+
*/
|
|
36
|
+
duration = 0;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Formatted duration string (hh:mm:ss or mm:ss)
|
|
40
|
+
*/
|
|
41
|
+
formattedDuration = "00:00";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Uniform Resource Identifier of the track
|
|
45
|
+
*/
|
|
46
|
+
uri: string | null = null;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* International Standard Recording Code
|
|
50
|
+
*/
|
|
51
|
+
isrc: string | null = null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* URL of the track (validated URI)
|
|
55
|
+
*/
|
|
56
|
+
url: string | null = null;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Artwork/thumbnail URL
|
|
60
|
+
*/
|
|
61
|
+
artworkUrl: string | null = null;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Custom user data attached to the track
|
|
65
|
+
*/
|
|
66
|
+
userData = {} as UserData;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Additional info from plugins
|
|
70
|
+
*/
|
|
71
|
+
pluginInfo = {} as PluginInfo;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Encoded string representation (Lavalink format)
|
|
75
|
+
*/
|
|
76
|
+
encoded: string;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Source name (youtube, spotify, soundcloud, etc.)
|
|
80
|
+
*/
|
|
81
|
+
sourceName = "unknown";
|
|
82
|
+
|
|
83
|
+
get identifier(): string {
|
|
84
|
+
return this.id;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get stream(): boolean {
|
|
88
|
+
return this.isLive;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get seekable(): boolean {
|
|
92
|
+
return this.isSeekable;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get durationFormatted(): string {
|
|
96
|
+
return this.formattedDuration;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
get source(): string {
|
|
100
|
+
return this.sourceName;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get thumbnail(): string | null {
|
|
104
|
+
return this.artworkUrl;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get info() {
|
|
108
|
+
return {
|
|
109
|
+
identifier: this.id,
|
|
110
|
+
position: 0,
|
|
111
|
+
title: this.title,
|
|
112
|
+
author: this.author,
|
|
113
|
+
length: this.duration,
|
|
114
|
+
isStream: this.isLive,
|
|
115
|
+
isSeekable: this.isSeekable,
|
|
116
|
+
uri: this.uri,
|
|
117
|
+
isrc: this.isrc,
|
|
118
|
+
artworkUrl: this.artworkUrl,
|
|
119
|
+
sourceName: this.sourceName,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
constructor(data: APITrack<UserData, PluginInfo>) {
|
|
124
|
+
if (!isRecord(data)) {
|
|
125
|
+
throw new Error("Track data must be an object");
|
|
126
|
+
}
|
|
127
|
+
if (!isRecord(data.info)) {
|
|
128
|
+
throw new Error("Track info is not an object");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Validate and set identifier
|
|
132
|
+
if (isString(data.info.identifier, "non-empty")) {
|
|
133
|
+
this.id = data.info.identifier;
|
|
134
|
+
} else {
|
|
135
|
+
throw new Error("Track does not have an identifier");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Validate and set encoded data
|
|
139
|
+
if (isString(data.encoded, "non-empty")) {
|
|
140
|
+
this.encoded = data.encoded;
|
|
141
|
+
} else {
|
|
142
|
+
throw new Error("Track does not have an encoded data string");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Set title and author
|
|
146
|
+
if (isString(data.info.title, "non-empty")) {
|
|
147
|
+
this.title = data.info.title;
|
|
148
|
+
}
|
|
149
|
+
if (isString(data.info.author, "non-empty")) {
|
|
150
|
+
this.author = data.info.author;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Set stream and seekable flags
|
|
154
|
+
if (data.info.isStream) {
|
|
155
|
+
this.isLive = true;
|
|
156
|
+
}
|
|
157
|
+
if (data.info.isSeekable) {
|
|
158
|
+
this.isSeekable = true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Set duration
|
|
162
|
+
if (this.isLive) {
|
|
163
|
+
this.duration = Number.POSITIVE_INFINITY;
|
|
164
|
+
this.formattedDuration = "Live";
|
|
165
|
+
} else if (isNumber(data.info.length, "whole")) {
|
|
166
|
+
this.duration = data.info.length;
|
|
167
|
+
this.formattedDuration = formatDuration(this.duration);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Set URIs
|
|
171
|
+
if (isString(data.info.uri, "non-empty")) {
|
|
172
|
+
this.uri = data.info.uri;
|
|
173
|
+
}
|
|
174
|
+
if (isString(data.info.isrc, "non-empty")) {
|
|
175
|
+
this.isrc = data.info.isrc;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Validate URL
|
|
179
|
+
if (isString(this.uri, "url")) {
|
|
180
|
+
this.url = this.uri;
|
|
181
|
+
}
|
|
182
|
+
if (isString(data.info.artworkUrl, "url")) {
|
|
183
|
+
this.artworkUrl = data.info.artworkUrl;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Set user data and plugin info
|
|
187
|
+
if (isRecord(data.userData, "non-empty")) {
|
|
188
|
+
this.userData = data.userData;
|
|
189
|
+
}
|
|
190
|
+
if (isRecord(data.pluginInfo, "non-empty")) {
|
|
191
|
+
this.pluginInfo = data.pluginInfo;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Set source name
|
|
195
|
+
if (isString(data.info.sourceName, "non-empty")) {
|
|
196
|
+
this.sourceName = data.info.sourceName;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* String representation of the track
|
|
202
|
+
*/
|
|
203
|
+
toString(): string {
|
|
204
|
+
return this.title;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* JSON representation of the track
|
|
209
|
+
*/
|
|
210
|
+
toJSON(): Record<string, unknown> {
|
|
211
|
+
return {
|
|
212
|
+
identifier: this.id,
|
|
213
|
+
title: this.title,
|
|
214
|
+
author: this.author,
|
|
215
|
+
duration: this.duration,
|
|
216
|
+
uri: this.uri,
|
|
217
|
+
thumbnail: this.artworkUrl,
|
|
218
|
+
source: this.sourceName,
|
|
219
|
+
isSeekable: this.isSeekable,
|
|
220
|
+
isStream: this.isLive,
|
|
221
|
+
encoded: this.encoded,
|
|
222
|
+
userData: this.userData,
|
|
223
|
+
pluginInfo: this.pluginInfo,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create a clone of the track
|
|
229
|
+
*/
|
|
230
|
+
clone(): Track<UserData, PluginInfo> {
|
|
231
|
+
return new Track({
|
|
232
|
+
encoded: this.encoded,
|
|
233
|
+
info: {
|
|
234
|
+
...this.info,
|
|
235
|
+
length: this.duration,
|
|
236
|
+
isStream: this.isLive,
|
|
237
|
+
},
|
|
238
|
+
userData: this.userData,
|
|
239
|
+
pluginInfo: this.pluginInfo,
|
|
240
|
+
} as APITrack<UserData, PluginInfo>);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { Queue } from "../audio/AudioQueue";
|
|
3
|
+
import { Track } from "./AudioTrack";
|
|
4
|
+
import {
|
|
5
|
+
LookupSymbol,
|
|
6
|
+
OnStateUpdateSymbol,
|
|
7
|
+
OnEventUpdateSymbol,
|
|
8
|
+
OnPingUpdateSymbol,
|
|
9
|
+
OnVoiceCloseSymbol,
|
|
10
|
+
} from "../config/symbols";
|
|
11
|
+
import { noop } from "../utils";
|
|
12
|
+
import { EventType, TrackEndReason } from "../types/api/Websocket";
|
|
13
|
+
import type { Player } from "../core/RyanlinkPlayer";
|
|
14
|
+
import type {
|
|
15
|
+
CreateQueueOptions,
|
|
16
|
+
QueueContext,
|
|
17
|
+
APIPlayer,
|
|
18
|
+
PlayerState,
|
|
19
|
+
EventPayload,
|
|
20
|
+
TrackStartEventPayload,
|
|
21
|
+
TrackEndEventPayload,
|
|
22
|
+
TrackExceptionEventPayload,
|
|
23
|
+
TrackStuckEventPayload,
|
|
24
|
+
WebSocketClosedEventPayload,
|
|
25
|
+
} from "../types";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Manages all queues across guilds
|
|
29
|
+
* Handles queue creation, destruction, synchronization, and event forwarding
|
|
30
|
+
*/
|
|
31
|
+
export class QueueManager<Context extends Record<string, unknown> = QueueContext> extends EventEmitter {
|
|
32
|
+
#player: Player;
|
|
33
|
+
#queues = new Map<string, Queue<Context>>();
|
|
34
|
+
#players = new Map<string, APIPlayer>();
|
|
35
|
+
|
|
36
|
+
constructor(player: Player) {
|
|
37
|
+
super({ captureRejections: false });
|
|
38
|
+
this.#player = player;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get(guildId: string): Queue<Context> | undefined {
|
|
42
|
+
return this.#queues.get(guildId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
has(guildId: string): boolean {
|
|
46
|
+
return this.#queues.has(guildId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get all(): Queue<Context>[] {
|
|
50
|
+
return Array.from(this.#queues.values());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get size(): number {
|
|
54
|
+
return this.#queues.size;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
keys(): IterableIterator<string> {
|
|
58
|
+
return this.#queues.keys();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
values(): IterableIterator<Queue<Context>> {
|
|
62
|
+
return this.#queues.values();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
entries(): IterableIterator<[string, Queue<Context>]> {
|
|
66
|
+
return this.#queues.entries();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async create(options: CreateQueueOptions<Context>): Promise<Queue<Context>> {
|
|
70
|
+
if (this.#queues.has(options.guildId)) {
|
|
71
|
+
throw new Error(`Queue already exists for guild '${options.guildId}'`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let voice = this.#player.voices.get(options.guildId);
|
|
75
|
+
if (!voice) {
|
|
76
|
+
voice = await this.#player.voices.connect(options.guildId, options.voiceId, {
|
|
77
|
+
node: options.node,
|
|
78
|
+
context: options.context,
|
|
79
|
+
filters: options.filters,
|
|
80
|
+
volume: options.volume,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const playerState: APIPlayer = {
|
|
85
|
+
guildId: options.guildId,
|
|
86
|
+
track: null,
|
|
87
|
+
volume: options.volume ?? 100,
|
|
88
|
+
paused: false,
|
|
89
|
+
state: {
|
|
90
|
+
time: Date.now(),
|
|
91
|
+
position: 0,
|
|
92
|
+
connected: false,
|
|
93
|
+
ping: -1,
|
|
94
|
+
},
|
|
95
|
+
voice: {
|
|
96
|
+
token: "",
|
|
97
|
+
endpoint: "",
|
|
98
|
+
sessionId: "",
|
|
99
|
+
channelId: options.voiceId,
|
|
100
|
+
},
|
|
101
|
+
filters: options.filters ?? {},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
this.#players.set(options.guildId, playerState);
|
|
105
|
+
|
|
106
|
+
const queue = new Queue(this.#player, options.guildId, options.context);
|
|
107
|
+
this.#queues.set(options.guildId, queue);
|
|
108
|
+
|
|
109
|
+
this.#player.emit("queueCreate", queue);
|
|
110
|
+
|
|
111
|
+
return queue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async destroy(guildId: string, reason = "destroyed"): Promise<void> {
|
|
115
|
+
const queue = this.#queues.get(guildId);
|
|
116
|
+
if (!queue) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const voice = this.#player.voices.get(guildId);
|
|
121
|
+
if (voice?.node) {
|
|
122
|
+
await voice.node.rest.destroyPlayer(guildId).catch(noop);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await this.#player.voices.destroy(guildId, reason);
|
|
126
|
+
|
|
127
|
+
this.#queues.delete(guildId);
|
|
128
|
+
this.#players.delete(guildId);
|
|
129
|
+
|
|
130
|
+
this.#player.emit("queueDestroy", queue, reason);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async relocate(guildId: string, nodeName: string): Promise<void> {
|
|
134
|
+
const queue = this.#queues.get(guildId);
|
|
135
|
+
if (!queue) {
|
|
136
|
+
throw new Error(`No queue found for guild '${guildId}'`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const voice = this.#player.voices.get(guildId);
|
|
140
|
+
if (!voice) {
|
|
141
|
+
throw new Error(`No voice connection found for guild '${guildId}'`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await voice.changeNode(nodeName);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async syncAll(): Promise<void> {
|
|
148
|
+
const promises: Promise<void>[] = [];
|
|
149
|
+
|
|
150
|
+
for (const queue of this.#queues.values()) {
|
|
151
|
+
promises.push(queue.sync("remote").catch(noop));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await Promise.all(promises);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
[LookupSymbol](guildId: string): APIPlayer | undefined {
|
|
158
|
+
return this.#players.get(guildId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
[OnStateUpdateSymbol](guildId: string, state: PlayerState): void {
|
|
162
|
+
const player = this.#players.get(guildId);
|
|
163
|
+
if (!player) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
player.state = state;
|
|
168
|
+
|
|
169
|
+
const queue = this.#queues.get(guildId);
|
|
170
|
+
if (queue) {
|
|
171
|
+
this.#player.emit("queueUpdate", queue, state);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const voice = this.#player.voices.get(guildId);
|
|
175
|
+
if (voice?.regionId) {
|
|
176
|
+
const region = this.#player.voices.regions.get(voice.regionId);
|
|
177
|
+
if (region) {
|
|
178
|
+
(region as unknown as { [OnPingUpdateSymbol]?(nodeName: string, state: PlayerState): void })[
|
|
179
|
+
OnPingUpdateSymbol
|
|
180
|
+
]?.(voice.node.name, state);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
[OnEventUpdateSymbol](guildId: string, event: EventPayload): void {
|
|
186
|
+
const queue = this.#queues.get(guildId);
|
|
187
|
+
if (!queue) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
switch (event.type) {
|
|
192
|
+
case EventType.TrackStart:
|
|
193
|
+
this.#handleTrackStart(queue, event);
|
|
194
|
+
break;
|
|
195
|
+
case EventType.TrackEnd:
|
|
196
|
+
void this.#handleTrackEnd(queue, event);
|
|
197
|
+
break;
|
|
198
|
+
case EventType.TrackException:
|
|
199
|
+
this.#handleTrackException(queue, event);
|
|
200
|
+
break;
|
|
201
|
+
case EventType.TrackStuck:
|
|
202
|
+
this.#handleTrackStuck(queue, event);
|
|
203
|
+
break;
|
|
204
|
+
case EventType.WebSocketClosed:
|
|
205
|
+
this.#handleWebSocketClosed(queue, event);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#handleTrackStart(queue: Queue<Context>, event: TrackStartEventPayload): void {
|
|
211
|
+
const track = new Track(event.track);
|
|
212
|
+
this.#player.emit("trackStart", queue, track);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async #handleTrackEnd(queue: Queue<Context>, event: TrackEndEventPayload): Promise<void> {
|
|
216
|
+
const track = new Track(event.track);
|
|
217
|
+
const reason = event.reason;
|
|
218
|
+
|
|
219
|
+
this.#player.emit("trackFinish", queue, track, reason);
|
|
220
|
+
|
|
221
|
+
const shouldAdvance = reason === TrackEndReason.Finished || reason === TrackEndReason.LoadFailed;
|
|
222
|
+
|
|
223
|
+
if (shouldAdvance) {
|
|
224
|
+
const nextTrack = await queue.next().catch(noop);
|
|
225
|
+
|
|
226
|
+
if (!nextTrack && queue.finished) {
|
|
227
|
+
this.#player.emit("queueFinish", queue);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#handleTrackException(queue: Queue<Context>, event: TrackExceptionEventPayload): void {
|
|
233
|
+
const track = new Track(event.track);
|
|
234
|
+
this.#player.emit("trackError", queue, track, event.exception);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#handleTrackStuck(queue: Queue<Context>, event: TrackStuckEventPayload): void {
|
|
238
|
+
const track = new Track(event.track);
|
|
239
|
+
this.#player.emit("trackStuck", queue, track, event.thresholdMs);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#handleWebSocketClosed(queue: Queue<Context>, event: WebSocketClosedEventPayload): void {
|
|
243
|
+
const voice = this.#player.voices.get(queue.guildId);
|
|
244
|
+
if (voice) {
|
|
245
|
+
(
|
|
246
|
+
this.#player.voices as unknown as {
|
|
247
|
+
[OnVoiceCloseSymbol]?(guildId: string, code: number, reason: string, byRemote: boolean): void;
|
|
248
|
+
}
|
|
249
|
+
)[OnVoiceCloseSymbol]?.(queue.guildId, event.code, event.reason, event.byRemote);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { formatDuration, isArray, isNumber, isRecord, isString } from "../utils";
|
|
2
|
+
import { Track } from "./AudioTrack";
|
|
3
|
+
import type { APIPlaylist, CommonPluginInfo, JsonObject } from "../types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Represents a playlist containing multiple tracks
|
|
7
|
+
*/
|
|
8
|
+
export class Playlist<PluginInfo extends JsonObject = CommonPluginInfo> {
|
|
9
|
+
/**
|
|
10
|
+
* Name of the playlist
|
|
11
|
+
*/
|
|
12
|
+
name = "Unknown Playlist";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Index of the track that was selected (from URL)
|
|
16
|
+
*/
|
|
17
|
+
selectedTrack = -1;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* List of tracks in the playlist
|
|
21
|
+
*/
|
|
22
|
+
tracks: Track[] = [];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Additional info from plugins
|
|
26
|
+
*/
|
|
27
|
+
pluginInfo = {} as PluginInfo;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Total duration of all tracks in milliseconds
|
|
31
|
+
*/
|
|
32
|
+
duration = 0;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Formatted total duration string
|
|
36
|
+
*/
|
|
37
|
+
formattedDuration = "00:00";
|
|
38
|
+
|
|
39
|
+
constructor(data: APIPlaylist<PluginInfo>) {
|
|
40
|
+
if (!isRecord(data)) {
|
|
41
|
+
throw new Error("Playlist data must be an object");
|
|
42
|
+
}
|
|
43
|
+
if (!isRecord(data.info)) {
|
|
44
|
+
throw new Error("Playlist info is not an object");
|
|
45
|
+
}
|
|
46
|
+
if (!isArray(data.tracks)) {
|
|
47
|
+
throw new Error("Playlist tracks must be an array");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Process tracks and calculate duration
|
|
51
|
+
for (let i = 0; i < data.tracks.length; i++) {
|
|
52
|
+
const track = new Track(data.tracks[i]);
|
|
53
|
+
if (!track.isLive) {
|
|
54
|
+
this.duration += track.duration;
|
|
55
|
+
}
|
|
56
|
+
this.tracks.push(track);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Set playlist name
|
|
60
|
+
if (isString(data.info.name, "non-empty")) {
|
|
61
|
+
this.name = data.info.name;
|
|
62
|
+
} else if (data.info.name === "") {
|
|
63
|
+
this.name = "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Set selected track index
|
|
67
|
+
if (isNumber(data.info.selectedTrack, "whole")) {
|
|
68
|
+
this.selectedTrack = data.info.selectedTrack;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Set plugin info
|
|
72
|
+
if (isRecord(data.pluginInfo, "non-empty")) {
|
|
73
|
+
this.pluginInfo = data.pluginInfo;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Format duration
|
|
77
|
+
if (this.duration > 0) {
|
|
78
|
+
this.formattedDuration = formatDuration(this.duration);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get selected(): Track | null {
|
|
83
|
+
if (this.selectedTrack < 0 || this.selectedTrack >= this.tracks.length) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return this.tracks[this.selectedTrack] ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get the selected track (if any)
|
|
91
|
+
* @returns The selected track or undefined if none/invalid
|
|
92
|
+
*/
|
|
93
|
+
getSelectedTrack(): Track | undefined {
|
|
94
|
+
return this.selected ?? undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get the number of tracks
|
|
99
|
+
*/
|
|
100
|
+
get length(): number {
|
|
101
|
+
return this.tracks.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get the number of tracks (alias for length)
|
|
106
|
+
*/
|
|
107
|
+
get trackCount(): number {
|
|
108
|
+
return this.tracks.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Formatted total duration (alias for formattedDuration)
|
|
113
|
+
*/
|
|
114
|
+
get durationFormatted(): string {
|
|
115
|
+
return this.formattedDuration;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* String representation of the playlist
|
|
120
|
+
*/
|
|
121
|
+
toString(): string {
|
|
122
|
+
return this.name;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* JSON representation of the playlist
|
|
127
|
+
*/
|
|
128
|
+
toJSON(): Record<string, unknown> {
|
|
129
|
+
return {
|
|
130
|
+
name: this.name,
|
|
131
|
+
selectedTrack: this.selectedTrack,
|
|
132
|
+
tracks: this.tracks.map((t) => t.toJSON()),
|
|
133
|
+
duration: this.duration,
|
|
134
|
+
formattedDuration: this.formattedDuration,
|
|
135
|
+
pluginInfo: this.pluginInfo,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audio exports - AudioTrack, TrackCollection, AudioFilters, AudioQueue, QueueController
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export * from "./AudioTrack";
|
|
6
|
+
export * from "./TrackCollection";
|
|
7
|
+
export * from "./AudioFilters";
|
|
8
|
+
export * from "./AudioQueue";
|
|
9
|
+
export * from "./QueueController";
|