ziplayer 0.2.7-dev.3 → 0.3.0

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.
Files changed (47) hide show
  1. package/AI-Guide.md +624 -607
  2. package/README.md +526 -524
  3. package/dist/plugins/index.d.ts +62 -12
  4. package/dist/plugins/index.d.ts.map +1 -1
  5. package/dist/plugins/index.js +497 -57
  6. package/dist/plugins/index.js.map +1 -1
  7. package/dist/structures/PersistenceManager.d.ts +96 -0
  8. package/dist/structures/PersistenceManager.d.ts.map +1 -0
  9. package/dist/structures/PersistenceManager.js +1008 -0
  10. package/dist/structures/PersistenceManager.js.map +1 -0
  11. package/dist/structures/Player.d.ts +109 -18
  12. package/dist/structures/Player.d.ts.map +1 -1
  13. package/dist/structures/Player.js +902 -182
  14. package/dist/structures/Player.js.map +1 -1
  15. package/dist/structures/PlayerManager.d.ts +1 -22
  16. package/dist/structures/PlayerManager.d.ts.map +1 -1
  17. package/dist/structures/PlayerManager.js +1 -73
  18. package/dist/structures/PlayerManager.js.map +1 -1
  19. package/dist/structures/StreamManager.d.ts +137 -0
  20. package/dist/structures/StreamManager.d.ts.map +1 -0
  21. package/dist/structures/StreamManager.js +420 -0
  22. package/dist/structures/StreamManager.js.map +1 -0
  23. package/dist/types/index.d.ts +149 -16
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/dist/types/index.js +0 -1
  26. package/dist/types/index.js.map +1 -1
  27. package/dist/types/persistence.d.ts +3 -2
  28. package/dist/types/persistence.d.ts.map +1 -1
  29. package/package.json +47 -47
  30. package/src/extensions/BaseExtension.ts +36 -36
  31. package/src/extensions/index.ts +473 -473
  32. package/src/index.ts +16 -16
  33. package/src/plugins/BasePlugin.ts +27 -27
  34. package/src/plugins/index.ts +950 -403
  35. package/src/structures/FilterManager.ts +303 -303
  36. package/src/structures/Player.ts +2797 -1970
  37. package/src/structures/PlayerManager.ts +725 -822
  38. package/src/structures/Queue.ts +599 -599
  39. package/src/structures/StreamManager.ts +524 -0
  40. package/src/types/extension.ts +129 -129
  41. package/src/types/fillter.ts +264 -264
  42. package/src/types/index.ts +548 -415
  43. package/src/types/plugin.ts +59 -59
  44. package/src/utils/timeout.ts +10 -10
  45. package/tsconfig.json +22 -22
  46. package/src/persistence/PersistenceManager.ts +0 -1077
  47. package/src/types/persistence.ts +0 -85
@@ -1,822 +1,725 @@
1
- import { EventEmitter } from "events";
2
- import { Player } from "./Player";
3
- import {
4
- PlayerManagerOptions,
5
- PlayerOptions,
6
- Track,
7
- SourcePlugin,
8
- SearchResult,
9
- ManagerEvents,
10
- PlayerStats,
11
- PersistenceOptions,
12
- DestroyedRecord,
13
- } from "../types";
14
- import type { BaseExtension } from "../extensions";
15
- import { withTimeout } from "../utils/timeout";
16
- import { PersistenceManager } from "../persistence/PersistenceManager";
17
-
18
- const GLOBAL_MANAGER_KEY: symbol = Symbol.for("ziplayer.PlayerManager.instance");
19
-
20
- export const getGlobalManager = (): PlayerManager | null => {
21
- try {
22
- const instance = (globalThis as any)[GLOBAL_MANAGER_KEY];
23
- if (!instance) {
24
- return null;
25
- }
26
- return instance as PlayerManager;
27
- } catch (error) {
28
- console.error("[PlayerManager] Error getting global instance:", error);
29
- return null;
30
- }
31
- };
32
-
33
- const setGlobalManager = (instance: PlayerManager): void => {
34
- try {
35
- (globalThis as any)[GLOBAL_MANAGER_KEY] = instance;
36
- } catch (error) {
37
- console.error("[PlayerManager] Error setting global instance:", error);
38
- }
39
- };
40
-
41
- export declare interface PlayerManager {
42
- on<K extends keyof ManagerEvents>(event: K, listener: (...args: ManagerEvents[K]) => void): this;
43
- emit<K extends keyof ManagerEvents>(event: K, ...args: ManagerEvents[K]): boolean;
44
- }
45
-
46
- interface ManagerCacheEntry<T> {
47
- data: T;
48
- timestamp: number;
49
- expiresAt: number;
50
- }
51
-
52
- /**
53
- * The main class for managing players across multiple Discord guilds.
54
- *
55
- * @example
56
- * // Basic setup with plugins and extensions
57
- * const manager = new PlayerManager({
58
- * plugins: [
59
- * new YouTubePlugin(),
60
- * new SoundCloudPlugin(),
61
- * new SpotifyPlugin(),
62
- * new TTSPlugin({ defaultLang: "en" })
63
- * ],
64
- * extensions: [
65
- * new voiceExt(null, { lang: "en-US" }),
66
- * new lavalinkExt(null, {
67
- * nodes: [{ host: "localhost", port: 2333, password: "youshallnotpass" }]
68
- * })
69
- * ],
70
- * extractorTimeout: 10000,
71
- * autoCleanup: true,
72
- * cleanupInterval: 60000
73
- * });
74
- *
75
- * // Create a player for a guild
76
- * const player = await manager.create(guildId, {
77
- * tts: { interrupt: true, volume: 1 },
78
- * leaveOnEnd: true,
79
- * leaveTimeout: 30000
80
- * });
81
- *
82
- * // Get existing player
83
- * const existingPlayer = manager.get(guildId);
84
- * if (existingPlayer) {
85
- * await existingPlayer.play("Never Gonna Give You Up", userId);
86
- * }
87
- */
88
- export class PlayerManager extends EventEmitter {
89
- private static instance: PlayerManager | null = null;
90
- private players: Map<string, Player> = new Map();
91
- private searchCache: Map<string, ManagerCacheEntry<SearchResult>>;
92
- private readonly SEARCH_CACHE_TTL = 60 * 1000; // 1 minute
93
- private readonly MAX_CACHE_SIZE = 100;
94
- private cleanupInterval: NodeJS.Timeout | null = null;
95
- private statsInterval: NodeJS.Timeout | null = null;
96
-
97
- private persistenceManager?: PersistenceManager;
98
- static async default(opt?: PlayerOptions): Promise<Player> {
99
- let globaldef = getGlobalManager();
100
- if (!globaldef) {
101
- globaldef = new PlayerManager({});
102
- }
103
- return await globaldef.create("default", opt);
104
- }
105
-
106
- private plugins: SourcePlugin[];
107
- private extensions: any[];
108
- private B_debug: boolean = false;
109
- private extractorTimeout: number = 10000;
110
- private autoCleanup: boolean = true;
111
- private cleanupTimeout: number = 60000; // 1 minute
112
- private enableSearchCache: boolean = true;
113
-
114
- private debug(message?: any, ...optionalParams: any[]): void {
115
- if (this.listenerCount("debug") > 0) {
116
- this.emit("debug", `[PlayerManager] ${message}`, ...optionalParams);
117
- if (!this.B_debug) {
118
- this.B_debug = true;
119
- }
120
- }
121
- }
122
-
123
- constructor(options: PlayerManagerOptions = {}) {
124
- super();
125
- this.plugins = [];
126
- this.searchCache = new Map();
127
-
128
- // Initialize plugins
129
- const provided = options.plugins || [];
130
- for (const p of provided as any[]) {
131
- try {
132
- if (p && typeof p === "object") {
133
- this.plugins.push(p as SourcePlugin);
134
- } else if (typeof p === "function") {
135
- const instance = new (p as any)();
136
- this.plugins.push(instance as SourcePlugin);
137
- }
138
- this.debug(`Registered plugin: ${p.name || "unnamed"}`);
139
- } catch (e) {
140
- this.debug(`Failed to init plugin:`, e);
141
- }
142
- }
143
-
144
- this.extensions = options.extensions || [];
145
- this.extractorTimeout = options.extractorTimeout ?? 10000;
146
- this.autoCleanup = options.autoCleanup ?? true;
147
- this.cleanupTimeout = options.cleanupInterval ?? 60000;
148
- this.enableSearchCache = options.enableSearchCache ?? true;
149
-
150
- if (options.persistence) {
151
- this.initPersistence(options.persistence);
152
- }
153
- // Setup auto cleanup
154
- if (this.autoCleanup) {
155
- this.startAutoCleanup();
156
- }
157
-
158
- // Setup stats collection (optional)
159
- if (options.enableStatsCollection) {
160
- this.startStatsCollection();
161
- }
162
-
163
- setGlobalManager(this);
164
- this.debug(`Initialized with ${this.plugins.length} plugins, ${this.extensions.length} extensions`);
165
- }
166
-
167
- private withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
168
- const timeout = this.extractorTimeout;
169
- return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), timeout))]);
170
- }
171
-
172
- private resolveGuildId(guildOrId: string | { id: string }): string {
173
- if (typeof guildOrId === "string") return guildOrId;
174
- if (guildOrId && typeof guildOrId === "object" && "id" in guildOrId) return guildOrId.id;
175
- throw new Error("Invalid guild or guildId provided.");
176
- }
177
-
178
- private getSearchCacheKey(query: string): string {
179
- return query.toLowerCase().trim();
180
- }
181
-
182
- private getCachedSearch(query: string): SearchResult | null {
183
- if (!this.enableSearchCache) return null;
184
-
185
- const key = this.getSearchCacheKey(query);
186
- const cached = this.searchCache.get(key);
187
-
188
- if (cached && Date.now() < cached.expiresAt) {
189
- this.debug(`[Cache] Search hit for: ${query}`);
190
- return cached.data;
191
- }
192
-
193
- if (cached) {
194
- this.searchCache.delete(key);
195
- }
196
-
197
- return null;
198
- }
199
-
200
- private setCachedSearch(query: string, result: SearchResult): void {
201
- if (!this.enableSearchCache) return;
202
-
203
- // Clean up old entries if cache is too large
204
- if (this.searchCache.size >= this.MAX_CACHE_SIZE) {
205
- const oldest = Array.from(this.searchCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
206
- if (oldest) this.searchCache.delete(oldest[0]);
207
- }
208
-
209
- const key = this.getSearchCacheKey(query);
210
- this.searchCache.set(key, {
211
- data: result,
212
- timestamp: Date.now(),
213
- expiresAt: Date.now() + this.SEARCH_CACHE_TTL,
214
- });
215
- this.debug(`[Cache] Search stored for: ${query}`);
216
- }
217
-
218
- private clearExpiredCache(): void {
219
- const now = Date.now();
220
- let expiredCount = 0;
221
-
222
- for (const [key, entry] of this.searchCache) {
223
- if (now >= entry.expiresAt) {
224
- this.searchCache.delete(key);
225
- expiredCount++;
226
- }
227
- }
228
-
229
- if (expiredCount > 0) {
230
- this.debug(`[Cache] Cleared ${expiredCount} expired search entries`);
231
- }
232
- }
233
-
234
- private startAutoCleanup(): void {
235
- if (this.cleanupInterval) {
236
- clearInterval(this.cleanupInterval);
237
- }
238
-
239
- this.cleanupInterval = setInterval(() => {
240
- this.cleanupInactivePlayers();
241
- this.clearExpiredCache();
242
- }, this.cleanupTimeout);
243
-
244
- this.debug(`Auto-cleanup started with interval: ${this.cleanupTimeout}ms`);
245
- }
246
-
247
- private startStatsCollection(): void {
248
- if (this.statsInterval) {
249
- clearInterval(this.statsInterval);
250
- }
251
-
252
- this.statsInterval = setInterval(() => {
253
- const stats = this.getStats();
254
- this.emit("stats", stats);
255
- }, 30000); // Every 30 seconds
256
- }
257
-
258
- private cleanupInactivePlayers(): void {
259
- let cleanedCount = 0;
260
-
261
- for (const [guildId, player] of this.players) {
262
- // Clean up players that are not playing and not connected
263
- if (!player.isPlaying && !player.connection && player.queue.isEmpty) {
264
- const idleTime = Date.now() - (player as any)._lastActivity || Date.now();
265
- if (idleTime > this.cleanupTimeout) {
266
- this.debug(`Cleaning up inactive player for guild: ${guildId}`);
267
- player.destroy();
268
- this.players.delete(guildId);
269
- cleanedCount++;
270
- }
271
- }
272
- }
273
-
274
- if (cleanedCount > 0) {
275
- this.debug(`Cleaned up ${cleanedCount} inactive players`);
276
- }
277
- }
278
-
279
- /**
280
- * Create a new player for a guild
281
- *
282
- * @param {string | {id: string}} guildOrId - Guild ID or guild object
283
- * @param {PlayerOptions} options - Player configuration options
284
- * @returns {Promise<Player>} The created player instance
285
- */
286
- async create(guildOrId: string | { id: string }, options?: PlayerOptions): Promise<Player> {
287
- const guildId = this.resolveGuildId(guildOrId);
288
-
289
- if (this.players.has(guildId)) {
290
- this.debug(`Player already exists for guildId: ${guildId}, returning existing`);
291
- return this.players.get(guildId)!;
292
- }
293
-
294
- this.debug(`Creating player for guildId: ${guildId}`);
295
- const player = new Player(guildId, options, this);
296
-
297
- // Add all registered plugins
298
- this.plugins.forEach((plugin) => player.addPlugin(plugin));
299
-
300
- // Activate extensions
301
- let extsToActivate: any[] = [];
302
- const optExts = (options as any)?.extensions as any[] | string[] | undefined;
303
-
304
- if (Array.isArray(optExts)) {
305
- if (optExts.length === 0) {
306
- extsToActivate = [];
307
- } else if (typeof optExts[0] === "string") {
308
- const wanted = new Set(optExts as string[]);
309
- extsToActivate = this.extensions.filter((ext) => {
310
- const name = typeof ext === "function" ? ext.name : ext?.name;
311
- return !!name && wanted.has(name);
312
- });
313
- } else {
314
- extsToActivate = optExts;
315
- }
316
- } else {
317
- // Use all extensions by default
318
- extsToActivate = this.extensions;
319
- }
320
-
321
- for (const ext of extsToActivate) {
322
- let instance = ext;
323
- if (typeof ext === "function") {
324
- try {
325
- instance = new ext(player);
326
- } catch (e) {
327
- this.debug(`Extension constructor error for ${ext.name}:`, e);
328
- continue;
329
- }
330
- }
331
-
332
- if (instance && typeof instance === "object") {
333
- const extInstance = instance as BaseExtension;
334
- if ("player" in extInstance && !extInstance.player) extInstance.player = player;
335
- player.attachExtension(extInstance);
336
-
337
- if (typeof extInstance.active === "function") {
338
- let activated: boolean | void = true;
339
- try {
340
- activated = await withTimeout(
341
- Promise.resolve(extInstance.active({ manager: this, player })),
342
- player.options.extractorTimeout ?? 15000,
343
- `Extension ${extInstance?.name} activation timed out`,
344
- );
345
- this.debug(`Extension ${extInstance?.name} active check returned: ${activated}`);
346
- } catch (e) {
347
- activated = false;
348
- this.debug(`Extension activation error for ${extInstance?.name}:`, e);
349
- }
350
-
351
- if (activated === false) {
352
- player.detachExtension(extInstance);
353
- continue;
354
- }
355
- }
356
- }
357
- }
358
-
359
- // Forward all player events to manager
360
- this.setupEventForwarding(player, guildId);
361
-
362
- // Mark last activity
363
- (player as any)._lastActivity = Date.now();
364
-
365
- this.players.set(guildId, player);
366
- this.debug(`Player created for guildId: ${guildId}`);
367
- return player;
368
- }
369
-
370
- private setupEventForwarding(player: Player, guildId: string): void {
371
- const forwardEvents = {
372
- willPlay: "willPlay",
373
- trackStart: "trackStart",
374
- trackEnd: "trackEnd",
375
- queueEnd: "queueEnd",
376
- playerError: "playerError",
377
- connectionError: "connectionError",
378
- volumeChange: "volumeChange",
379
- queueAdd: "queueAdd",
380
- queueAddList: "queueAddList",
381
- queueRemove: "queueRemove",
382
- playerPause: "playerPause",
383
- playerResume: "playerResume",
384
- playerStop: "playerStop",
385
- ttsStart: "ttsStart",
386
- ttsEnd: "ttsEnd",
387
- } as const satisfies Record<string, keyof ManagerEvents>;
388
-
389
- for (const [sourceEvent, targetEvent] of Object.entries(forwardEvents) as [
390
- keyof typeof forwardEvents,
391
- keyof ManagerEvents,
392
- ][]) {
393
- player.on(sourceEvent, (...args: any[]) => {
394
- if (sourceEvent === "trackStart") {
395
- player._lastActivity = Date.now();
396
- }
397
-
398
- (this.emit as any)(targetEvent, player, ...args);
399
- });
400
- }
401
-
402
- player.on("playerDestroy", () => {
403
- this.emit("playerDestroy", player);
404
-
405
- this.players.delete(guildId);
406
-
407
- this.debug(`Player destroyed for guildId: ${guildId}`);
408
- });
409
-
410
- player.on("debug", (...args) => {
411
- if (this.listenerCount("debug") > 0) {
412
- this.emit("debug", ...args);
413
- }
414
- });
415
- }
416
- /**
417
- * Get an existing player for a guild
418
- *
419
- * @param {string | {id: string}} guildOrId - Guild ID or guild object
420
- * @returns {Player | undefined} The player instance or undefined if not found
421
- */
422
- get(guildOrId: string | { id: string }): Player | undefined {
423
- const guildId = this.resolveGuildId(guildOrId);
424
- const player = this.players.get(guildId);
425
- if (player) {
426
- (player as any)._lastActivity = Date.now();
427
- }
428
- return player;
429
- }
430
-
431
- /**
432
- * Get an existing player for a guild (alias for get)
433
- */
434
- getPlayer(guildOrId: string | { id: string }): Player | undefined {
435
- return this.get(guildOrId);
436
- }
437
-
438
- /**
439
- * Get all players
440
- *
441
- * @returns {Player[]} All player instances
442
- */
443
- getAll(): Player[] {
444
- return Array.from(this.players.values());
445
- }
446
-
447
- /**
448
- * Alias for getAll
449
- */
450
- getall(): Player[] {
451
- return this.getAll();
452
- }
453
-
454
- /**
455
- * Get players by filter
456
- *
457
- * @param {(player: Player) => boolean} filter - Filter function
458
- * @returns {Player[]} Filtered player instances
459
- */
460
- getPlayersByFilter(filter: (player: Player) => boolean): Player[] {
461
- return this.getAll().filter(filter);
462
- }
463
-
464
- /**
465
- * Get players in a voice channel
466
- *
467
- * @param {string} channelId - Voice channel ID
468
- * @returns {Player[]} Players in the channel
469
- */
470
- getPlayersInChannel(channelId: string): Player[] {
471
- return this.getAll().filter((p) => p.connection?.joinConfig.channelId === channelId);
472
- }
473
-
474
- /**
475
- * Destroy a player and clean up resources
476
- *
477
- * @param {string | {id: string}} guildOrId - Guild ID or guild object
478
- * @returns {boolean} True if player was destroyed, false if not found
479
- */
480
- delete(guildOrId: string | { id: string }): boolean {
481
- const guildId = this.resolveGuildId(guildOrId);
482
- const player = this.players.get(guildId);
483
-
484
- if (player) {
485
- this.debug(`Deleting player for guildId: ${guildId}`);
486
- player.destroy();
487
- return true;
488
- }
489
- return false;
490
- }
491
-
492
- /**
493
- * Destroy multiple players by filter
494
- *
495
- * @param {(player: Player) => boolean} filter - Filter function
496
- * @returns {number} Number of players destroyed
497
- */
498
- deleteWhere(filter: (player: Player) => boolean): number {
499
- const toDelete = this.getPlayersByFilter(filter);
500
- let count = 0;
501
-
502
- for (const player of toDelete) {
503
- const guildId = player.guildId;
504
- player.destroy();
505
- this.players.delete(guildId);
506
- count++;
507
- }
508
-
509
- if (count > 0) {
510
- this.debug(`Deleted ${count} players by filter`);
511
- }
512
- return count;
513
- }
514
-
515
- /**
516
- * Check if a player exists for a guild
517
- *
518
- * @param {string | {id: string}} guildOrId - Guild ID or guild object
519
- * @returns {boolean} True if player exists
520
- */
521
- has(guildOrId: string | { id: string }): boolean {
522
- const guildId = this.resolveGuildId(guildOrId);
523
- return this.players.has(guildId);
524
- }
525
-
526
- /**
527
- * Get number of players
528
- */
529
- get size(): number {
530
- return this.players.size;
531
- }
532
-
533
- /**
534
- * Check if debug is enabled
535
- */
536
- get debugEnabled(): boolean {
537
- return this.B_debug;
538
- }
539
-
540
- /**
541
- * Get manager statistics
542
- *
543
- * @returns {PlayerStats} Statistics about players
544
- */
545
- getStats(): PlayerStats {
546
- let activePlayers = 0;
547
- let pausedPlayers = 0;
548
- let connectedPlayers = 0;
549
- let totalTracksInQueue = 0;
550
-
551
- for (const player of this.players.values()) {
552
- if (player.isPlaying) activePlayers++;
553
- if (player.isPaused) pausedPlayers++;
554
- if (player.connection) connectedPlayers++;
555
- totalTracksInQueue += player.queueSize;
556
- }
557
-
558
- return {
559
- totalPlayers: this.players.size,
560
- activePlayers,
561
- pausedPlayers,
562
- connectedPlayers,
563
- totalTracksInQueue,
564
- };
565
- }
566
-
567
- /**
568
- * Broadcast an action to all players
569
- *
570
- * @param {string} action - Action to perform
571
- * @param {...any[]} args - Arguments for the action
572
- * @example
573
- * manager.broadcast("setVolume", 50);
574
- * manager.broadcast("pause");
575
- */
576
- broadcast(action: string, ...args: any[]): void {
577
- for (const player of this.players.values()) {
578
- if (typeof (player as any)[action] === "function") {
579
- try {
580
- (player as any)[action](...args);
581
- } catch (error) {
582
- this.debug(`Error broadcasting ${action} to ${player.guildId}:`, error);
583
- }
584
- }
585
- }
586
- }
587
-
588
- /**
589
- * Destroy all players and clean up
590
- */
591
- destroy(): void {
592
- this.debug(`Destroying all players`);
593
-
594
- if (this.persistenceManager) {
595
- this.persistenceManager.saveAll().catch((err) => {
596
- this.debug("Failed to save players before destroy:", err);
597
- });
598
- this.persistenceManager.shutdown().catch(console.error);
599
- }
600
- // Stop cleanup intervals
601
- if (this.cleanupInterval) {
602
- clearInterval(this.cleanupInterval);
603
- this.cleanupInterval = null;
604
- }
605
-
606
- if (this.statsInterval) {
607
- clearInterval(this.statsInterval);
608
- this.statsInterval = null;
609
- }
610
-
611
- // Destroy all players
612
- for (const player of this.players.values()) {
613
- player.destroy();
614
- }
615
-
616
- this.players.clear();
617
- this.searchCache.clear();
618
- this.removeAllListeners();
619
- this.debug(`PlayerManager destroyed`);
620
- }
621
-
622
- /**
623
- * Search using registered plugins without creating a Player.
624
- *
625
- * @param {string} query - The query to search for
626
- * @param {string} requestedBy - The user ID who requested the search
627
- * @returns {Promise<SearchResult>} The search result
628
- */
629
- async search(query: string, requestedBy: string): Promise<SearchResult> {
630
- this.debug(`Search called with query: ${query}, requestedBy: ${requestedBy}`);
631
-
632
- // Check cache first
633
- const cached = this.getCachedSearch(query);
634
- if (cached) {
635
- return cached;
636
- }
637
-
638
- const plugin = this.plugins.find((p) => p.canHandle(query));
639
- if (!plugin) {
640
- this.debug(`No plugin found to handle: ${query}`);
641
- throw new Error(`No plugin found to handle: ${query}`);
642
- }
643
-
644
- try {
645
- const result = await this.withTimeout(plugin.search(query, requestedBy), "Search operation timed out");
646
- this.setCachedSearch(query, result);
647
- return result;
648
- } catch (error) {
649
- this.debug(`Search error:`, error);
650
- throw error as Error;
651
- }
652
- }
653
-
654
- /**
655
- * Clear search cache
656
- */
657
- clearSearchCache(): void {
658
- const size = this.searchCache.size;
659
- this.searchCache.clear();
660
- this.debug(`Cleared ${size} search cache entries`);
661
- }
662
-
663
- /**
664
- * Register a plugin after initialization
665
- *
666
- * @param {SourcePlugin} plugin - Plugin to register
667
- */
668
- registerPlugin(plugin: SourcePlugin): void {
669
- this.plugins.push(plugin);
670
- this.debug(`Registered plugin: ${plugin.name}`);
671
-
672
- // Register plugin with all existing players
673
- for (const player of this.players.values()) {
674
- player.addPlugin(plugin);
675
- }
676
- }
677
-
678
- /**
679
- * Unregister a plugin
680
- *
681
- * @param {string} name - Plugin name to unregister
682
- * @returns {boolean} True if plugin was unregistered
683
- */
684
- unregisterPlugin(name: string): boolean {
685
- const index = this.plugins.findIndex((p) => p.name === name);
686
- if (index === -1) return false;
687
-
688
- this.plugins.splice(index, 1);
689
- this.debug(`Unregistered plugin: ${name}`);
690
-
691
- // Note: Cannot easily remove plugins from existing players
692
- return true;
693
- }
694
-
695
- /**
696
- * Get all registered plugins
697
- */
698
- getPlugins(): SourcePlugin[] {
699
- return [...this.plugins];
700
- }
701
-
702
- /**
703
- * Register an extension after initialization
704
- *
705
- * @param {BaseExtension} extension - Extension to register
706
- */
707
- registerExtension(extension: BaseExtension): void {
708
- this.extensions.push(extension);
709
- this.debug(`Registered extension: ${extension.name}`);
710
-
711
- // Register extension with all existing players
712
- for (const player of this.players.values()) {
713
- player.attachExtension(extension);
714
- }
715
- }
716
-
717
- /**
718
- * Get manager configuration
719
- */
720
- getConfig(): object {
721
- return {
722
- extractorTimeout: this.extractorTimeout,
723
- autoCleanup: this.autoCleanup,
724
- cleanupTimeout: this.cleanupTimeout,
725
- enableSearchCache: this.enableSearchCache,
726
- pluginsCount: this.plugins.length,
727
- extensionsCount: this.extensions.length,
728
- playersCount: this.players.size,
729
- };
730
- }
731
- private initPersistence(persistenceOptions: PersistenceOptions): void {
732
- this.persistenceManager = new PersistenceManager(this, persistenceOptions);
733
-
734
- const forwardEvents = {
735
- playerSaved: "playerSaved",
736
- playerLoaded: "playerLoaded",
737
-
738
- playerSkipped: "RTSkipped",
739
- playerMarkedDestroyed: "RTMarkedDestroyed",
740
- playerDestroyedCleared: "RTDestroyedCleared",
741
-
742
- savedAll: "savedAll",
743
- loadedAll: "loadedAll",
744
-
745
- backupsCleaned: "backupsCleaned",
746
- allBackupsCleaned: "allBackupsCleaned",
747
-
748
- backupStats: "backupStats",
749
- backupCleanupDone: "backupCleanupDone",
750
- } as const satisfies Record<string, keyof ManagerEvents>;
751
-
752
- for (const [sourceEvent, targetEvent] of Object.entries(forwardEvents) as [
753
- keyof typeof forwardEvents,
754
- keyof ManagerEvents,
755
- ][]) {
756
- this.persistenceManager.on(sourceEvent, (...args: any[]) => {
757
- this.emit(targetEvent, ...(args as ManagerEvents[typeof targetEvent]));
758
- });
759
- }
760
-
761
- this.debug("Persistence manager initialized");
762
- }
763
-
764
- isAutoRestoreEnabled(): boolean {
765
- return this.persistenceManager?.isAutoRestoreEnabled() ?? false;
766
- }
767
-
768
- getDestroyedPlayers(): DestroyedRecord[] {
769
- return this.persistenceManager?.getDestroyedPlayers() ?? [];
770
- }
771
-
772
- /**
773
- * Get persistence manager
774
- */
775
- getPersistence(): PersistenceManager | undefined {
776
- return this.persistenceManager;
777
- }
778
-
779
- /**
780
- * Save all players
781
- */
782
- async saveAllPlayers(): Promise<Map<string, boolean>> {
783
- if (!this.persistenceManager) {
784
- throw new Error("Persistence not enabled");
785
- }
786
- return await this.persistenceManager.saveAll();
787
- }
788
-
789
- /**
790
- * Load all players
791
- */
792
- async loadAllPlayers(restorePosition: boolean = true): Promise<Map<string, boolean>> {
793
- if (!this.persistenceManager) {
794
- throw new Error("Persistence not enabled");
795
- }
796
- return await this.persistenceManager.loadAll(restorePosition);
797
- }
798
-
799
- /**
800
- * Save a specific player
801
- */
802
- async savePlayer(guildId: string): Promise<boolean> {
803
- if (!this.persistenceManager) return false;
804
- const player = this.get(guildId);
805
- if (!player) return false;
806
- return await this.persistenceManager.savePlayer(player);
807
- }
808
- }
809
-
810
- /**
811
- * Get the global PlayerManager instance
812
- *
813
- * @returns {PlayerManager | null} Global instance or null
814
- */
815
- export function getInstance(): PlayerManager | null {
816
- const globalInst = getGlobalManager();
817
- if (!globalInst) {
818
- console.error("[PlayerManager] Global instance not found, make sure to initialize with new PlayerManager(options)");
819
- return null;
820
- }
821
- return globalInst;
822
- }
1
+ import { EventEmitter } from "events";
2
+ import { Player } from "./Player";
3
+ import { PlayerManagerOptions, PlayerOptions, Track, SourcePlugin, SearchResult, ManagerEvents, PlayerStats } from "../types";
4
+ import type { BaseExtension } from "../extensions";
5
+ import { withTimeout } from "../utils/timeout";
6
+
7
+ const GLOBAL_MANAGER_KEY: symbol = Symbol.for("ziplayer.PlayerManager.instance");
8
+
9
+ export const getGlobalManager = (): PlayerManager | null => {
10
+ try {
11
+ const instance = (globalThis as any)[GLOBAL_MANAGER_KEY];
12
+ if (!instance) {
13
+ return null;
14
+ }
15
+ return instance as PlayerManager;
16
+ } catch (error) {
17
+ console.error("[PlayerManager] Error getting global instance:", error);
18
+ return null;
19
+ }
20
+ };
21
+
22
+ const setGlobalManager = (instance: PlayerManager): void => {
23
+ try {
24
+ (globalThis as any)[GLOBAL_MANAGER_KEY] = instance;
25
+ } catch (error) {
26
+ console.error("[PlayerManager] Error setting global instance:", error);
27
+ }
28
+ };
29
+
30
+ export declare interface PlayerManager {
31
+ on<K extends keyof ManagerEvents>(event: K, listener: (...args: ManagerEvents[K]) => void): this;
32
+ emit<K extends keyof ManagerEvents>(event: K, ...args: ManagerEvents[K]): boolean;
33
+ }
34
+
35
+ interface ManagerCacheEntry<T> {
36
+ data: T;
37
+ timestamp: number;
38
+ expiresAt: number;
39
+ }
40
+
41
+ /**
42
+ * The main class for managing players across multiple Discord guilds.
43
+ *
44
+ * @example
45
+ * // Basic setup with plugins and extensions
46
+ * const manager = new PlayerManager({
47
+ * plugins: [
48
+ * new YouTubePlugin(),
49
+ * new SoundCloudPlugin(),
50
+ * new SpotifyPlugin(),
51
+ * new TTSPlugin({ defaultLang: "en" })
52
+ * ],
53
+ * extensions: [
54
+ * new voiceExt(null, { lang: "en-US" }),
55
+ * new lavalinkExt(null, {
56
+ * nodes: [{ host: "localhost", port: 2333, password: "youshallnotpass" }]
57
+ * })
58
+ * ],
59
+ * extractorTimeout: 10000,
60
+ * autoCleanup: true,
61
+ * cleanupInterval: 60000
62
+ * });
63
+ *
64
+ * // Create a player for a guild
65
+ * const player = await manager.create(guildId, {
66
+ * tts: { interrupt: true, volume: 1 },
67
+ * leaveOnEnd: true,
68
+ * leaveTimeout: 30000
69
+ * });
70
+ *
71
+ * // Get existing player
72
+ * const existingPlayer = manager.get(guildId);
73
+ * if (existingPlayer) {
74
+ * await existingPlayer.play("Never Gonna Give You Up", userId);
75
+ * }
76
+ */
77
+ export class PlayerManager extends EventEmitter {
78
+ private static instance: PlayerManager | null = null;
79
+ private players: Map<string, Player> = new Map();
80
+ private searchCache: Map<string, ManagerCacheEntry<SearchResult>>;
81
+ private readonly SEARCH_CACHE_TTL = 60 * 1000; // 1 minute
82
+ private readonly MAX_CACHE_SIZE = 100;
83
+ private cleanupInterval: NodeJS.Timeout | null = null;
84
+ private statsInterval: NodeJS.Timeout | null = null;
85
+
86
+ static async default(opt?: PlayerOptions): Promise<Player> {
87
+ let globaldef = getGlobalManager();
88
+ if (!globaldef) {
89
+ globaldef = new PlayerManager({});
90
+ }
91
+ return await globaldef.create("default", opt);
92
+ }
93
+
94
+ private plugins: SourcePlugin[];
95
+ private extensions: any[];
96
+ private B_debug: boolean = false;
97
+ private extractorTimeout: number = 10000;
98
+ private autoCleanup: boolean = true;
99
+ private cleanupTimeout: number = 60000; // 1 minute
100
+ private enableSearchCache: boolean = true;
101
+
102
+ private debug(message?: any, ...optionalParams: any[]): void {
103
+ if (this.listenerCount("debug") > 0) {
104
+ this.emit("debug", `[PlayerManager] ${message}`, ...optionalParams);
105
+ if (!this.B_debug) {
106
+ this.B_debug = true;
107
+ }
108
+ }
109
+ }
110
+
111
+ constructor(options: PlayerManagerOptions = {}) {
112
+ super();
113
+ this.plugins = [];
114
+ this.searchCache = new Map();
115
+
116
+ // Initialize plugins
117
+ const provided = options.plugins || [];
118
+ for (const p of provided as any[]) {
119
+ try {
120
+ if (p && typeof p === "object") {
121
+ this.plugins.push(p as SourcePlugin);
122
+ } else if (typeof p === "function") {
123
+ const instance = new (p as any)();
124
+ this.plugins.push(instance as SourcePlugin);
125
+ }
126
+ this.debug(`Registered plugin: ${p.name || "unnamed"}`);
127
+ } catch (e) {
128
+ this.debug(`Failed to init plugin:`, e);
129
+ }
130
+ }
131
+
132
+ this.extensions = options.extensions || [];
133
+ this.extractorTimeout = options.extractorTimeout ?? 10000;
134
+ this.autoCleanup = options.autoCleanup ?? true;
135
+ this.cleanupTimeout = options.cleanupInterval ?? 60000;
136
+ this.enableSearchCache = options.enableSearchCache ?? true;
137
+
138
+ // Setup auto cleanup
139
+ if (this.autoCleanup) {
140
+ this.startAutoCleanup();
141
+ }
142
+
143
+ // Setup stats collection (optional)
144
+ if (options.enableStatsCollection) {
145
+ this.startStatsCollection();
146
+ }
147
+
148
+ setGlobalManager(this);
149
+ this.debug(`Initialized with ${this.plugins.length} plugins, ${this.extensions.length} extensions`);
150
+ }
151
+
152
+ private withTimeout<T>(promise: Promise<T>, message: string): Promise<T> {
153
+ const timeout = this.extractorTimeout;
154
+ return Promise.race([promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(message)), timeout))]);
155
+ }
156
+
157
+ private resolveGuildId(guildOrId: string | { id: string }): string {
158
+ if (typeof guildOrId === "string") return guildOrId;
159
+ if (guildOrId && typeof guildOrId === "object" && "id" in guildOrId) return guildOrId.id;
160
+ throw new Error("Invalid guild or guildId provided.");
161
+ }
162
+
163
+ private getSearchCacheKey(query: string): string {
164
+ return query.toLowerCase().trim();
165
+ }
166
+
167
+ private getCachedSearch(query: string): SearchResult | null {
168
+ if (!this.enableSearchCache) return null;
169
+
170
+ const key = this.getSearchCacheKey(query);
171
+ const cached = this.searchCache.get(key);
172
+
173
+ if (cached && Date.now() < cached.expiresAt) {
174
+ this.debug(`[Cache] Search hit for: ${query}`);
175
+ return cached.data;
176
+ }
177
+
178
+ if (cached) {
179
+ this.searchCache.delete(key);
180
+ }
181
+
182
+ return null;
183
+ }
184
+
185
+ private setCachedSearch(query: string, result: SearchResult): void {
186
+ if (!this.enableSearchCache) return;
187
+
188
+ // Clean up old entries if cache is too large
189
+ if (this.searchCache.size >= this.MAX_CACHE_SIZE) {
190
+ const oldest = Array.from(this.searchCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
191
+ if (oldest) this.searchCache.delete(oldest[0]);
192
+ }
193
+
194
+ const key = this.getSearchCacheKey(query);
195
+ this.searchCache.set(key, {
196
+ data: result,
197
+ timestamp: Date.now(),
198
+ expiresAt: Date.now() + this.SEARCH_CACHE_TTL,
199
+ });
200
+ this.debug(`[Cache] Search stored for: ${query}`);
201
+ }
202
+
203
+ private clearExpiredCache(): void {
204
+ const now = Date.now();
205
+ let expiredCount = 0;
206
+
207
+ for (const [key, entry] of this.searchCache) {
208
+ if (now >= entry.expiresAt) {
209
+ this.searchCache.delete(key);
210
+ expiredCount++;
211
+ }
212
+ }
213
+
214
+ if (expiredCount > 0) {
215
+ this.debug(`[Cache] Cleared ${expiredCount} expired search entries`);
216
+ }
217
+ }
218
+
219
+ private startAutoCleanup(): void {
220
+ if (this.cleanupInterval) {
221
+ clearInterval(this.cleanupInterval);
222
+ }
223
+
224
+ this.cleanupInterval = setInterval(() => {
225
+ this.cleanupInactivePlayers();
226
+ this.clearExpiredCache();
227
+ }, this.cleanupTimeout);
228
+
229
+ this.debug(`Auto-cleanup started with interval: ${this.cleanupTimeout}ms`);
230
+ }
231
+
232
+ private startStatsCollection(): void {
233
+ if (this.statsInterval) {
234
+ clearInterval(this.statsInterval);
235
+ }
236
+
237
+ this.statsInterval = setInterval(() => {
238
+ const stats = this.getStats();
239
+ this.emit("stats", stats);
240
+ }, 30000); // Every 30 seconds
241
+ }
242
+
243
+ private cleanupInactivePlayers(): void {
244
+ let cleanedCount = 0;
245
+
246
+ for (const [guildId, player] of this.players) {
247
+ // Clean up players that are not playing and not connected
248
+ if (!player.isPlaying && !player.connection && player.queue.isEmpty) {
249
+ const idleTime = Date.now() - (player as any)._lastActivity || Date.now();
250
+ if (idleTime > this.cleanupTimeout) {
251
+ this.debug(`Cleaning up inactive player for guild: ${guildId}`);
252
+ player.destroy();
253
+ this.players.delete(guildId);
254
+ cleanedCount++;
255
+ }
256
+ }
257
+ }
258
+
259
+ if (cleanedCount > 0) {
260
+ this.debug(`Cleaned up ${cleanedCount} inactive players`);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Create a new player for a guild
266
+ *
267
+ * @param {string | {id: string}} guildOrId - Guild ID or guild object
268
+ * @param {PlayerOptions} options - Player configuration options
269
+ * @returns {Promise<Player>} The created player instance
270
+ */
271
+ async create(guildOrId: string | { id: string }, options?: PlayerOptions): Promise<Player> {
272
+ const guildId = this.resolveGuildId(guildOrId);
273
+
274
+ if (this.players.has(guildId)) {
275
+ this.debug(`Player already exists for guildId: ${guildId}, returning existing`);
276
+ return this.players.get(guildId)!;
277
+ }
278
+
279
+ this.debug(`Creating player for guildId: ${guildId}`);
280
+ const player = new Player(guildId, options, this);
281
+
282
+ // Add all registered plugins
283
+ this.plugins.forEach((plugin) => player.addPlugin(plugin));
284
+
285
+ // Activate extensions
286
+ let extsToActivate: any[] = [];
287
+ const optExts = (options as any)?.extensions as any[] | string[] | undefined;
288
+
289
+ if (Array.isArray(optExts)) {
290
+ if (optExts.length === 0) {
291
+ extsToActivate = [];
292
+ } else if (typeof optExts[0] === "string") {
293
+ const wanted = new Set(optExts as string[]);
294
+ extsToActivate = this.extensions.filter((ext) => {
295
+ const name = typeof ext === "function" ? ext.name : ext?.name;
296
+ return !!name && wanted.has(name);
297
+ });
298
+ } else {
299
+ extsToActivate = optExts;
300
+ }
301
+ } else {
302
+ // Use all extensions by default
303
+ extsToActivate = this.extensions;
304
+ }
305
+
306
+ for (const ext of extsToActivate) {
307
+ let instance = ext;
308
+ if (typeof ext === "function") {
309
+ try {
310
+ instance = new ext(player);
311
+ } catch (e) {
312
+ this.debug(`Extension constructor error for ${ext.name}:`, e);
313
+ continue;
314
+ }
315
+ }
316
+
317
+ if (instance && typeof instance === "object") {
318
+ const extInstance = instance as BaseExtension;
319
+ if ("player" in extInstance && !extInstance.player) extInstance.player = player;
320
+ player.attachExtension(extInstance);
321
+
322
+ if (typeof extInstance.active === "function") {
323
+ let activated: boolean | void = true;
324
+ try {
325
+ activated = await withTimeout(
326
+ Promise.resolve(extInstance.active({ manager: this, player })),
327
+ player.options.extractorTimeout ?? 15000,
328
+ `Extension ${extInstance?.name} activation timed out`,
329
+ );
330
+ this.debug(`Extension ${extInstance?.name} active check returned: ${activated}`);
331
+ } catch (e) {
332
+ activated = false;
333
+ this.debug(`Extension activation error for ${extInstance?.name}:`, e);
334
+ }
335
+
336
+ if (activated === false) {
337
+ player.detachExtension(extInstance);
338
+ continue;
339
+ }
340
+ }
341
+ }
342
+ }
343
+
344
+ // Forward all player events to manager
345
+ this.setupEventForwarding(player, guildId);
346
+
347
+ // Mark last activity
348
+ (player as any)._lastActivity = Date.now();
349
+
350
+ this.players.set(guildId, player);
351
+ this.debug(`Player created for guildId: ${guildId}`);
352
+ return player;
353
+ }
354
+
355
+ private setupEventForwarding(player: Player, guildId: string): void {
356
+ const forwardEvents = {
357
+ willPlay: "willPlay",
358
+ trackStart: "trackStart",
359
+ trackEnd: "trackEnd",
360
+ queueEnd: "queueEnd",
361
+ playerError: "playerError",
362
+ connectionError: "connectionError",
363
+ volumeChange: "volumeChange",
364
+ queueAdd: "queueAdd",
365
+ queueAddList: "queueAddList",
366
+ queueRemove: "queueRemove",
367
+ playerPause: "playerPause",
368
+ playerResume: "playerResume",
369
+ playerStop: "playerStop",
370
+ ttsStart: "ttsStart",
371
+ ttsEnd: "ttsEnd",
372
+ streamError: "streamError",
373
+ } as const satisfies Record<string, keyof ManagerEvents>;
374
+
375
+ for (const [sourceEvent, targetEvent] of Object.entries(forwardEvents) as [
376
+ keyof typeof forwardEvents,
377
+ keyof ManagerEvents,
378
+ ][]) {
379
+ player.on(sourceEvent, (...args: any[]) => {
380
+ if (sourceEvent === "trackStart") {
381
+ player._lastActivity = Date.now();
382
+ }
383
+
384
+ (this.emit as any)(targetEvent, player, ...args);
385
+ });
386
+ }
387
+
388
+ player.on("playerDestroy", () => {
389
+ this.emit("playerDestroy", player);
390
+
391
+ this.players.delete(guildId);
392
+
393
+ this.debug(`Player destroyed for guildId: ${guildId}`);
394
+ });
395
+
396
+ player.on("debug", (...args) => {
397
+ if (this.listenerCount("debug") > 0) {
398
+ this.emit("debug", ...args);
399
+ }
400
+ });
401
+ }
402
+ /**
403
+ * Get an existing player for a guild
404
+ *
405
+ * @param {string | {id: string}} guildOrId - Guild ID or guild object
406
+ * @returns {Player | undefined} The player instance or undefined if not found
407
+ */
408
+ get(guildOrId: string | { id: string }): Player | undefined {
409
+ const guildId = this.resolveGuildId(guildOrId);
410
+ const player = this.players.get(guildId);
411
+ if (player) {
412
+ (player as any)._lastActivity = Date.now();
413
+ }
414
+ return player;
415
+ }
416
+
417
+ /**
418
+ * Get an existing player for a guild (alias for get)
419
+ */
420
+ getPlayer(guildOrId: string | { id: string }): Player | undefined {
421
+ return this.get(guildOrId);
422
+ }
423
+
424
+ /**
425
+ * Get all players
426
+ *
427
+ * @returns {Player[]} All player instances
428
+ */
429
+ getAll(): Player[] {
430
+ return Array.from(this.players.values());
431
+ }
432
+
433
+ /**
434
+ * Alias for getAll
435
+ */
436
+ getall(): Player[] {
437
+ return this.getAll();
438
+ }
439
+
440
+ /**
441
+ * Get players by filter
442
+ *
443
+ * @param {(player: Player) => boolean} filter - Filter function
444
+ * @returns {Player[]} Filtered player instances
445
+ */
446
+ getPlayersByFilter(filter: (player: Player) => boolean): Player[] {
447
+ return this.getAll().filter(filter);
448
+ }
449
+
450
+ /**
451
+ * Get players in a voice channel
452
+ *
453
+ * @param {string} channelId - Voice channel ID
454
+ * @returns {Player[]} Players in the channel
455
+ */
456
+ getPlayersInChannel(channelId: string): Player[] {
457
+ return this.getAll().filter((p) => p.connection?.joinConfig.channelId === channelId);
458
+ }
459
+
460
+ /**
461
+ * Destroy a player and clean up resources
462
+ *
463
+ * @param {string | {id: string}} guildOrId - Guild ID or guild object
464
+ * @returns {boolean} True if player was destroyed, false if not found
465
+ */
466
+ delete(guildOrId: string | { id: string }): boolean {
467
+ const guildId = this.resolveGuildId(guildOrId);
468
+ const player = this.players.get(guildId);
469
+
470
+ if (player) {
471
+ this.debug(`Deleting player for guildId: ${guildId}`);
472
+ player.destroy();
473
+ return true;
474
+ }
475
+ return false;
476
+ }
477
+
478
+ /**
479
+ * Destroy multiple players by filter
480
+ *
481
+ * @param {(player: Player) => boolean} filter - Filter function
482
+ * @returns {number} Number of players destroyed
483
+ */
484
+ deleteWhere(filter: (player: Player) => boolean): number {
485
+ const toDelete = this.getPlayersByFilter(filter);
486
+ let count = 0;
487
+
488
+ for (const player of toDelete) {
489
+ const guildId = player.guildId;
490
+ player.destroy();
491
+ this.players.delete(guildId);
492
+ count++;
493
+ }
494
+
495
+ if (count > 0) {
496
+ this.debug(`Deleted ${count} players by filter`);
497
+ }
498
+ return count;
499
+ }
500
+
501
+ /**
502
+ * Check if a player exists for a guild
503
+ *
504
+ * @param {string | {id: string}} guildOrId - Guild ID or guild object
505
+ * @returns {boolean} True if player exists
506
+ */
507
+ has(guildOrId: string | { id: string }): boolean {
508
+ const guildId = this.resolveGuildId(guildOrId);
509
+ return this.players.has(guildId);
510
+ }
511
+
512
+ /**
513
+ * Get number of players
514
+ */
515
+ get size(): number {
516
+ return this.players.size;
517
+ }
518
+
519
+ /**
520
+ * Check if debug is enabled
521
+ */
522
+ get debugEnabled(): boolean {
523
+ return this.B_debug;
524
+ }
525
+
526
+ /**
527
+ * Get manager statistics
528
+ *
529
+ * @returns {PlayerStats} Statistics about players
530
+ */
531
+ getStats(): PlayerStats {
532
+ let activePlayers = 0;
533
+ let pausedPlayers = 0;
534
+ let connectedPlayers = 0;
535
+ let totalTracksInQueue = 0;
536
+
537
+ for (const player of this.players.values()) {
538
+ if (player.isPlaying) activePlayers++;
539
+ if (player.isPaused) pausedPlayers++;
540
+ if (player.connection) connectedPlayers++;
541
+ totalTracksInQueue += player.queueSize;
542
+ }
543
+
544
+ return {
545
+ totalPlayers: this.players.size,
546
+ activePlayers,
547
+ pausedPlayers,
548
+ connectedPlayers,
549
+ totalTracksInQueue,
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Broadcast an action to all players
555
+ *
556
+ * @param {string} action - Action to perform
557
+ * @param {...any[]} args - Arguments for the action
558
+ * @example
559
+ * manager.broadcast("setVolume", 50);
560
+ * manager.broadcast("pause");
561
+ */
562
+ broadcast(action: string, ...args: any[]): void {
563
+ for (const player of this.players.values()) {
564
+ if (typeof (player as any)[action] === "function") {
565
+ try {
566
+ (player as any)[action](...args);
567
+ } catch (error) {
568
+ this.debug(`Error broadcasting ${action} to ${player.guildId}:`, error);
569
+ }
570
+ }
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Destroy all players and clean up
576
+ */
577
+ destroy(): void {
578
+ this.debug(`Destroying all players`);
579
+
580
+ // Stop cleanup intervals
581
+ if (this.cleanupInterval) {
582
+ clearInterval(this.cleanupInterval);
583
+ this.cleanupInterval = null;
584
+ }
585
+
586
+ if (this.statsInterval) {
587
+ clearInterval(this.statsInterval);
588
+ this.statsInterval = null;
589
+ }
590
+
591
+ // Destroy all players
592
+ for (const player of this.players.values()) {
593
+ player.destroy();
594
+ }
595
+
596
+ this.players.clear();
597
+ this.searchCache.clear();
598
+ this.removeAllListeners();
599
+ this.debug(`PlayerManager destroyed`);
600
+ }
601
+
602
+ /**
603
+ * Search using registered plugins without creating a Player.
604
+ *
605
+ * @param {string} query - The query to search for
606
+ * @param {string} requestedBy - The user ID who requested the search
607
+ * @returns {Promise<SearchResult>} The search result
608
+ */
609
+ async search(query: string, requestedBy: string): Promise<SearchResult> {
610
+ this.debug(`Search called with query: ${query}, requestedBy: ${requestedBy}`);
611
+
612
+ // Check cache first
613
+ const cached = this.getCachedSearch(query);
614
+ if (cached) {
615
+ return cached;
616
+ }
617
+
618
+ const plugin = this.plugins.find((p) => p.canHandle(query));
619
+ if (!plugin) {
620
+ this.debug(`No plugin found to handle: ${query}`);
621
+ throw new Error(`No plugin found to handle: ${query}`);
622
+ }
623
+
624
+ try {
625
+ const result = await this.withTimeout(plugin.search(query, requestedBy), "Search operation timed out");
626
+ this.setCachedSearch(query, result);
627
+ return result;
628
+ } catch (error) {
629
+ this.debug(`Search error:`, error);
630
+ throw error as Error;
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Clear search cache
636
+ */
637
+ clearSearchCache(): void {
638
+ const size = this.searchCache.size;
639
+ this.searchCache.clear();
640
+ this.debug(`Cleared ${size} search cache entries`);
641
+ }
642
+
643
+ /**
644
+ * Register a plugin after initialization
645
+ *
646
+ * @param {SourcePlugin} plugin - Plugin to register
647
+ */
648
+ registerPlugin(plugin: SourcePlugin): void {
649
+ this.plugins.push(plugin);
650
+ this.debug(`Registered plugin: ${plugin.name}`);
651
+
652
+ // Register plugin with all existing players
653
+ for (const player of this.players.values()) {
654
+ player.addPlugin(plugin);
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Unregister a plugin
660
+ *
661
+ * @param {string} name - Plugin name to unregister
662
+ * @returns {boolean} True if plugin was unregistered
663
+ */
664
+ unregisterPlugin(name: string): boolean {
665
+ const index = this.plugins.findIndex((p) => p.name === name);
666
+ if (index === -1) return false;
667
+
668
+ this.plugins.splice(index, 1);
669
+ this.debug(`Unregistered plugin: ${name}`);
670
+
671
+ // Note: Cannot easily remove plugins from existing players
672
+ return true;
673
+ }
674
+
675
+ /**
676
+ * Get all registered plugins
677
+ */
678
+ getPlugins(): SourcePlugin[] {
679
+ return [...this.plugins];
680
+ }
681
+
682
+ /**
683
+ * Register an extension after initialization
684
+ *
685
+ * @param {BaseExtension} extension - Extension to register
686
+ */
687
+ registerExtension(extension: BaseExtension): void {
688
+ this.extensions.push(extension);
689
+ this.debug(`Registered extension: ${extension.name}`);
690
+
691
+ // Register extension with all existing players
692
+ for (const player of this.players.values()) {
693
+ player.attachExtension(extension);
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Get manager configuration
699
+ */
700
+ getConfig(): object {
701
+ return {
702
+ extractorTimeout: this.extractorTimeout,
703
+ autoCleanup: this.autoCleanup,
704
+ cleanupTimeout: this.cleanupTimeout,
705
+ enableSearchCache: this.enableSearchCache,
706
+ pluginsCount: this.plugins.length,
707
+ extensionsCount: this.extensions.length,
708
+ playersCount: this.players.size,
709
+ };
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Get the global PlayerManager instance
715
+ *
716
+ * @returns {PlayerManager | null} Global instance or null
717
+ */
718
+ export function getInstance(): PlayerManager | null {
719
+ const globalInst = getGlobalManager();
720
+ if (!globalInst) {
721
+ console.error("[PlayerManager] Global instance not found, make sure to initialize with new PlayerManager(options)");
722
+ return null;
723
+ }
724
+ return globalInst;
725
+ }