ziplayer 0.3.3 → 0.3.5

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 (40) hide show
  1. package/AGENTS.md +717 -653
  2. package/README.md +658 -639
  3. package/dist/extensions/BaseExtension.d.ts +10 -1
  4. package/dist/extensions/BaseExtension.d.ts.map +1 -1
  5. package/dist/extensions/BaseExtension.js +27 -1
  6. package/dist/extensions/BaseExtension.js.map +1 -1
  7. package/dist/extensions/index.d.ts.map +1 -1
  8. package/dist/extensions/index.js +24 -6
  9. package/dist/extensions/index.js.map +1 -1
  10. package/dist/plugins/index.d.ts.map +1 -1
  11. package/dist/plugins/index.js +104 -50
  12. package/dist/plugins/index.js.map +1 -1
  13. package/dist/structures/Player.d.ts +74 -43
  14. package/dist/structures/Player.d.ts.map +1 -1
  15. package/dist/structures/Player.js +443 -114
  16. package/dist/structures/Player.js.map +1 -1
  17. package/dist/structures/PlayerManager.d.ts +41 -6
  18. package/dist/structures/PlayerManager.d.ts.map +1 -1
  19. package/dist/structures/PlayerManager.js +94 -125
  20. package/dist/structures/PlayerManager.js.map +1 -1
  21. package/dist/structures/StreamManager.d.ts +1 -0
  22. package/dist/structures/StreamManager.d.ts.map +1 -1
  23. package/dist/structures/StreamManager.js +1 -0
  24. package/dist/structures/StreamManager.js.map +1 -1
  25. package/dist/types/extension.d.ts +3 -0
  26. package/dist/types/extension.d.ts.map +1 -1
  27. package/dist/types/index.d.ts +38 -11
  28. package/dist/types/index.d.ts.map +1 -1
  29. package/dist/types/index.js +7 -0
  30. package/dist/types/index.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/extensions/BaseExtension.ts +31 -1
  33. package/src/extensions/index.ts +30 -7
  34. package/src/plugins/index.ts +136 -53
  35. package/src/structures/Player.ts +2940 -2544
  36. package/src/structures/PlayerManager.ts +916 -955
  37. package/src/structures/Queue.ts +621 -621
  38. package/src/structures/StreamManager.ts +2 -0
  39. package/src/types/extension.ts +3 -0
  40. package/src/types/index.ts +43 -11
@@ -1,955 +1,916 @@
1
- import { EventEmitter } from "events";
2
- import { Player } from "./Player";
3
- import {
4
- PlayerManagerOptions,
5
- PlayerOptions,
6
- type Track,
7
- SourcePlugin,
8
- SearchResult,
9
- ManagerEvents,
10
- PlayerStats,
11
- type PlaybackMirrorOptions,
12
- type TrackMiddleware,
13
- normalizeTrackMiddleware,
14
- } from "../types";
15
- import type { BaseExtension } from "../extensions";
16
- import { withTimeout } from "../utils/timeout";
17
- import { PluginManager } from "../plugins";
18
-
19
- const GLOBAL_MANAGER_KEY: symbol = Symbol.for("ziplayer.PlayerManager.instance");
20
-
21
- export const getGlobalManager = (): PlayerManager | null => {
22
- try {
23
- const instance = (globalThis as any)[GLOBAL_MANAGER_KEY];
24
- if (!instance) {
25
- return null;
26
- }
27
- return instance as PlayerManager;
28
- } catch (error) {
29
- console.error("[PlayerManager] Error getting global instance:", error);
30
- return null;
31
- }
32
- };
33
-
34
- const setGlobalManager = (instance: PlayerManager): void => {
35
- try {
36
- (globalThis as any)[GLOBAL_MANAGER_KEY] = instance;
37
- } catch (error) {
38
- console.error("[PlayerManager] Error setting global instance:", error);
39
- }
40
- };
41
-
42
- export declare interface PlayerManager {
43
- on<K extends keyof ManagerEvents>(event: K, listener: (...args: ManagerEvents[K]) => void): this;
44
- emit<K extends keyof ManagerEvents>(event: K, ...args: ManagerEvents[K]): boolean;
45
- }
46
-
47
- interface ManagerCacheEntry<T> {
48
- data: T;
49
- timestamp: number;
50
- expiresAt: number;
51
- }
52
-
53
- /**
54
- * The main class for managing players across multiple Discord guilds.
55
- *
56
- * @example
57
- * // Basic setup with plugins and extensions
58
- * const manager = new PlayerManager({
59
- * plugins: [
60
- * new YouTubePlugin(),
61
- * new SoundCloudPlugin(),
62
- * new SpotifyPlugin(),
63
- * new TTSPlugin({ defaultLang: "en" })
64
- * ],
65
- * extensions: [
66
- * new voiceExt(null, { lang: "en-US" }),
67
- * new lavalinkExt(null, {
68
- * nodes: [{ host: "localhost", port: 2333, password: "youshallnotpass" }]
69
- * })
70
- * ],
71
- * extractorTimeout: 10000,
72
- * autoCleanup: true,
73
- * cleanupInterval: 60000
74
- * });
75
- *
76
- * // Create a player for a guild
77
- * const player = await manager.create(guildId, {
78
- * tts: { interrupt: true, volume: 1 },
79
- * leaveOnEnd: true,
80
- * leaveTimeout: 30000
81
- * });
82
- *
83
- * // Get existing player
84
- * const existingPlayer = manager.get(guildId);
85
- * if (existingPlayer) {
86
- * await existingPlayer.play("Never Gonna Give You Up", userId);
87
- * }
88
- */
89
- export class PlayerManager extends EventEmitter {
90
- private static instance: PlayerManager | null = null;
91
- private players: Map<string, Player> = new Map();
92
- private searchCache: Map<string, ManagerCacheEntry<SearchResult>>;
93
- private readonly SEARCH_CACHE_TTL = 60 * 1000; // 1 minute
94
- private readonly MAX_CACHE_SIZE = 100;
95
- private cleanupInterval: NodeJS.Timeout | null = null;
96
- private statsInterval: NodeJS.Timeout | null = null;
97
-
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 pluginManager: PluginManager;
108
- private extensions: any[];
109
- private B_debug: boolean = false;
110
- private extractorTimeout: number = 10000;
111
- private autoCleanup: boolean = true;
112
- private cleanupTimeout: number = 60000; // 1 minute
113
- private enableSearchCache: boolean = true;
114
- private trackMiddlewareFromOptions: TrackMiddleware[] = [];
115
- private playbackMirrorUnsubscribes = new Map<string, () => void>();
116
-
117
- private debug(message?: any, ...optionalParams: any[]): void {
118
- if (this.listenerCount("debug") > 0) {
119
- this.emit("debug", `[PlayerManager] ${message}`, ...optionalParams);
120
- if (!this.B_debug) {
121
- this.B_debug = true;
122
- }
123
- }
124
- }
125
-
126
- constructor(options: PlayerManagerOptions = {}) {
127
- super();
128
- this.plugins = [];
129
- this.pluginManager = new PluginManager(null as any, this, {
130
- extractorTimeout: this.extractorTimeout,
131
- });
132
- this.searchCache = new Map();
133
-
134
- // Initialize plugins
135
- const provided = options.plugins || [];
136
- for (const p of provided as any[]) {
137
- try {
138
- let instance: SourcePlugin | null = null;
139
-
140
- if (p && typeof p === "object") {
141
- instance = p as SourcePlugin;
142
- } else if (typeof p === "function") {
143
- instance = new (p as any)();
144
- }
145
-
146
- if (instance) {
147
- this.plugins.push(instance);
148
- this.pluginManager.register(instance);
149
- }
150
- this.debug(`Registered plugin: ${p.name || "unnamed"}`);
151
- } catch (e) {
152
- this.debug(`Failed to init plugin:`, e);
153
- }
154
- }
155
-
156
- this.extensions = options.extensions || [];
157
- this.extractorTimeout = options.extractorTimeout ?? 10000;
158
- this.autoCleanup = options.autoCleanup ?? true;
159
- this.cleanupTimeout = options.cleanupInterval ?? 60000;
160
- this.enableSearchCache = options.enableSearchCache ?? true;
161
- this.trackMiddlewareFromOptions = normalizeTrackMiddleware(options.trackMiddleware);
162
-
163
- // Setup auto cleanup
164
- if (this.autoCleanup) {
165
- this.startAutoCleanup();
166
- }
167
-
168
- // Setup stats collection (optional)
169
- if (options.enableStatsCollection) {
170
- this.startStatsCollection();
171
- }
172
-
173
- setGlobalManager(this);
174
- this.debug(`Initialized with ${this.plugins.length} plugins, ${this.extensions.length} extensions`);
175
- }
176
-
177
- private resolveGuildId(guildOrId: string | { id: string }): string {
178
- if (typeof guildOrId === "string") return guildOrId;
179
- if (guildOrId && typeof guildOrId === "object" && "id" in guildOrId) return guildOrId.id;
180
- throw new Error("Invalid guild or guildId provided.");
181
- }
182
-
183
- private getSearchCacheKey(query: string): string {
184
- return query.toLowerCase().trim();
185
- }
186
-
187
- private getCachedSearch(query: string): SearchResult | null {
188
- if (!this.enableSearchCache) return null;
189
-
190
- const key = this.getSearchCacheKey(query);
191
- const cached = this.searchCache.get(key);
192
-
193
- if (cached && Date.now() < cached.expiresAt) {
194
- this.debug(`[Cache] Search hit for: ${query}`);
195
- return cached.data;
196
- }
197
-
198
- if (cached) {
199
- this.searchCache.delete(key);
200
- }
201
-
202
- return null;
203
- }
204
-
205
- private setCachedSearch(query: string, result: SearchResult): void {
206
- if (!this.enableSearchCache) return;
207
-
208
- // Clean up old entries if cache is too large
209
- if (this.searchCache.size >= this.MAX_CACHE_SIZE) {
210
- const oldest = Array.from(this.searchCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
211
- if (oldest) this.searchCache.delete(oldest[0]);
212
- }
213
-
214
- const key = this.getSearchCacheKey(query);
215
- this.searchCache.set(key, {
216
- data: result,
217
- timestamp: Date.now(),
218
- expiresAt: Date.now() + this.SEARCH_CACHE_TTL,
219
- });
220
- this.debug(`[Cache] Search stored for: ${query}`);
221
- }
222
-
223
- private clearExpiredCache(): void {
224
- const now = Date.now();
225
- let expiredCount = 0;
226
-
227
- for (const [key, entry] of this.searchCache) {
228
- if (now >= entry.expiresAt) {
229
- this.searchCache.delete(key);
230
- expiredCount++;
231
- }
232
- }
233
-
234
- if (expiredCount > 0) {
235
- this.debug(`[Cache] Cleared ${expiredCount} expired search entries`);
236
- }
237
- }
238
-
239
- private startAutoCleanup(): void {
240
- if (this.cleanupInterval) {
241
- clearInterval(this.cleanupInterval);
242
- }
243
-
244
- this.cleanupInterval = setInterval(() => {
245
- this.cleanupInactivePlayers();
246
- this.clearExpiredCache();
247
- }, this.cleanupTimeout);
248
-
249
- this.debug(`Auto-cleanup started with interval: ${this.cleanupTimeout}ms`);
250
- }
251
-
252
- private startStatsCollection(): void {
253
- if (this.statsInterval) {
254
- clearInterval(this.statsInterval);
255
- }
256
-
257
- this.statsInterval = setInterval(() => {
258
- const stats = this.getStats();
259
- this.emit("stats", stats);
260
- }, 30000); // Every 30 seconds
261
- }
262
-
263
- private cleanupInactivePlayers(): void {
264
- let cleanedCount = 0;
265
-
266
- for (const [guildId, player] of this.players) {
267
- // Clean up players that are not playing and not connected
268
- if (!player.isPlaying && !player.connection && player.queue.isEmpty) {
269
- const idleTime = Date.now() - (player as any)._lastActivity || Date.now();
270
- if (idleTime > this.cleanupTimeout) {
271
- this.debug(`Cleaning up inactive player for guild: ${guildId}`);
272
- player.destroy();
273
- this.players.delete(guildId);
274
- cleanedCount++;
275
- }
276
- }
277
- }
278
-
279
- if (cleanedCount > 0) {
280
- this.debug(`Cleaned up ${cleanedCount} inactive players`);
281
- }
282
- }
283
-
284
- /**
285
- * Create a new player for a guild
286
- *
287
- * @param {string | {id: string}} guildOrId - Guild ID or guild object
288
- * @param {PlayerOptions} options - Player configuration options
289
- * @returns {Promise<Player>} The created player instance
290
- */
291
- async create(guildOrId: string | { id: string }, options?: PlayerOptions): Promise<Player> {
292
- const guildId = this.resolveGuildId(guildOrId);
293
-
294
- if (this.players.has(guildId)) {
295
- this.debug(`Player already exists for guildId: ${guildId}, returning existing`);
296
- return this.players.get(guildId)!;
297
- }
298
-
299
- this.debug(`Creating player for guildId: ${guildId}`);
300
- const player = new Player(guildId, options, this);
301
-
302
- // Add all registered plugins
303
- this.plugins.forEach((plugin) => player.addPlugin(plugin));
304
-
305
- // Activate extensions
306
- let extsToActivate: any[] = [];
307
- const optExts = (options as any)?.extensions as any[] | string[] | undefined;
308
-
309
- if (Array.isArray(optExts)) {
310
- if (optExts.length === 0) {
311
- extsToActivate = [];
312
- } else if (typeof optExts[0] === "string") {
313
- const wanted = new Set(optExts as string[]);
314
- extsToActivate = this.extensions.filter((ext) => {
315
- const name = typeof ext === "function" ? ext.name : ext?.name;
316
- return !!name && wanted.has(name);
317
- });
318
- } else {
319
- extsToActivate = optExts;
320
- }
321
- } else {
322
- // Use all extensions by default
323
- extsToActivate = this.extensions;
324
- }
325
-
326
- for (const ext of extsToActivate) {
327
- let instance = ext;
328
- if (typeof ext === "function") {
329
- try {
330
- instance = new ext(player);
331
- } catch (e) {
332
- this.debug(`Extension constructor error for ${ext.name}:`, e);
333
- continue;
334
- }
335
- }
336
-
337
- if (instance && typeof instance === "object") {
338
- const extInstance = instance as BaseExtension;
339
- if ("player" in extInstance && !extInstance.player) extInstance.player = player;
340
- player.attachExtension(extInstance);
341
-
342
- if (typeof extInstance.active === "function") {
343
- let activated: boolean | void = true;
344
- try {
345
- activated = await withTimeout(
346
- Promise.resolve(extInstance.active({ manager: this, player })),
347
- player.options.extractorTimeout ?? 15000,
348
- `Extension ${extInstance?.name} activation timed out`,
349
- );
350
- this.debug(`Extension ${extInstance?.name} active check returned: ${activated}`);
351
- } catch (e) {
352
- activated = false;
353
- this.debug(`Extension activation error for ${extInstance?.name}:`, e);
354
- }
355
-
356
- if (activated === false) {
357
- player.detachExtension(extInstance);
358
- continue;
359
- }
360
- }
361
- }
362
- }
363
-
364
- // Forward all player events to manager
365
- this.setupEventForwarding(player, guildId);
366
-
367
- // Mark last activity
368
- (player as any)._lastActivity = Date.now();
369
-
370
- this.players.set(guildId, player);
371
- this.debug(`Player created for guildId: ${guildId}`);
372
- return player;
373
- }
374
-
375
- private setupEventForwarding(player: Player, guildId: string): void {
376
- const forwardEvents = {
377
- willPlay: "willPlay",
378
- trackStart: "trackStart",
379
- trackEnd: "trackEnd",
380
- queueEnd: "queueEnd",
381
- playerError: "playerError",
382
- connectionError: "connectionError",
383
- volumeChange: "volumeChange",
384
- queueAdd: "queueAdd",
385
- queueAddList: "queueAddList",
386
- queueRemove: "queueRemove",
387
- playerPause: "playerPause",
388
- playerResume: "playerResume",
389
- playerStop: "playerStop",
390
- ttsStart: "ttsStart",
391
- ttsEnd: "ttsEnd",
392
- streamError: "streamError",
393
- } as const satisfies Record<string, keyof ManagerEvents>;
394
-
395
- for (const [sourceEvent, targetEvent] of Object.entries(forwardEvents) as [
396
- keyof typeof forwardEvents,
397
- keyof ManagerEvents,
398
- ][]) {
399
- player.on(sourceEvent, (...args: any[]) => {
400
- if (sourceEvent === "trackStart") {
401
- player._lastActivity = Date.now();
402
- }
403
-
404
- (this.emit as any)(targetEvent, player, ...args);
405
- });
406
- }
407
-
408
- player.on("playerDestroy", () => {
409
- this.emit("playerDestroy", player);
410
-
411
- this.players.delete(guildId);
412
-
413
- this.debug(`Player destroyed for guildId: ${guildId}`);
414
- });
415
-
416
- player.on("debug", (...args) => {
417
- if (this.listenerCount("debug") > 0) {
418
- this.emit("debug", ...args);
419
- }
420
- });
421
- }
422
- /**
423
- * Get an existing player for a guild
424
- *
425
- * @param {string | {id: string}} guildOrId - Guild ID or guild object
426
- * @returns {Player | undefined} The player instance or undefined if not found
427
- */
428
- get(guildOrId: string | { id: string }): Player | undefined {
429
- const guildId = this.resolveGuildId(guildOrId);
430
- const player = this.players.get(guildId);
431
- if (player) {
432
- (player as any)._lastActivity = Date.now();
433
- }
434
- return player;
435
- }
436
-
437
- /**
438
- * Get an existing player for a guild (alias for get)
439
- */
440
- getPlayer(guildOrId: string | { id: string }): Player | undefined {
441
- return this.get(guildOrId);
442
- }
443
-
444
- /**
445
- * Get all players
446
- *
447
- * @returns {Player[]} All player instances
448
- */
449
- getAll(): Player[] {
450
- return Array.from(this.players.values());
451
- }
452
-
453
- /**
454
- * Alias for getAll
455
- */
456
- getall(): Player[] {
457
- return this.getAll();
458
- }
459
-
460
- /**
461
- * Get players by filter
462
- *
463
- * @param {(player: Player) => boolean} filter - Filter function
464
- * @returns {Player[]} Filtered player instances
465
- */
466
- getPlayersByFilter(filter: (player: Player) => boolean): Player[] {
467
- return this.getAll().filter(filter);
468
- }
469
-
470
- /**
471
- * Get players in a voice channel
472
- *
473
- * @param {string} channelId - Voice channel ID
474
- * @returns {Player[]} Players in the channel
475
- */
476
- getPlayersInChannel(channelId: string): Player[] {
477
- return this.getAll().filter((p) => p.connection?.joinConfig.channelId === channelId);
478
- }
479
-
480
- /**
481
- * Destroy a player and clean up resources
482
- *
483
- * @param {string | {id: string}} guildOrId - Guild ID or guild object
484
- * @returns {boolean} True if player was destroyed, false if not found
485
- */
486
- delete(guildOrId: string | { id: string }): boolean {
487
- const guildId = this.resolveGuildId(guildOrId);
488
- const player = this.players.get(guildId);
489
-
490
- if (player) {
491
- this.debug(`Deleting player for guildId: ${guildId}`);
492
- player.destroy();
493
- return true;
494
- }
495
- return false;
496
- }
497
-
498
- /**
499
- * Destroy multiple players by filter
500
- *
501
- * @param {(player: Player) => boolean} filter - Filter function
502
- * @returns {number} Number of players destroyed
503
- */
504
- deleteWhere(filter: (player: Player) => boolean): number {
505
- const toDelete = this.getPlayersByFilter(filter);
506
- let count = 0;
507
-
508
- for (const player of toDelete) {
509
- const guildId = player.guildId;
510
- player.destroy();
511
- this.players.delete(guildId);
512
- count++;
513
- }
514
-
515
- if (count > 0) {
516
- this.debug(`Deleted ${count} players by filter`);
517
- }
518
- return count;
519
- }
520
-
521
- /**
522
- * Check if a player exists for a guild
523
- *
524
- * @param {string | {id: string}} guildOrId - Guild ID or guild object
525
- * @returns {boolean} True if player exists
526
- */
527
- has(guildOrId: string | { id: string }): boolean {
528
- const guildId = this.resolveGuildId(guildOrId);
529
- return this.players.has(guildId);
530
- }
531
-
532
- /**
533
- * Get number of players
534
- */
535
- get size(): number {
536
- return this.players.size;
537
- }
538
-
539
- /**
540
- * Check if debug is enabled
541
- */
542
- get debugEnabled(): boolean {
543
- return this.B_debug;
544
- }
545
-
546
- /**
547
- * Get manager statistics
548
- *
549
- * @returns {PlayerStats} Statistics about players
550
- */
551
- getStats(): PlayerStats {
552
- let activePlayers = 0;
553
- let pausedPlayers = 0;
554
- let connectedPlayers = 0;
555
- let totalTracksInQueue = 0;
556
-
557
- for (const player of this.players.values()) {
558
- if (player.isPlaying) activePlayers++;
559
- if (player.isPaused) pausedPlayers++;
560
- if (player.connection) connectedPlayers++;
561
- totalTracksInQueue += player.queueSize;
562
- }
563
-
564
- return {
565
- totalPlayers: this.players.size,
566
- activePlayers,
567
- pausedPlayers,
568
- connectedPlayers,
569
- totalTracksInQueue,
570
- };
571
- }
572
-
573
- /**
574
- * Broadcast an action to all players
575
- *
576
- * @param {string} action - Action to perform
577
- * @param {...any[]} args - Arguments for the action
578
- * @example
579
- * manager.broadcast("setVolume", 50);
580
- * manager.broadcast("pause");
581
- */
582
- broadcast(action: string, ...args: any[]): void {
583
- for (const player of this.players.values()) {
584
- if (typeof (player as any)[action] === "function") {
585
- try {
586
- (player as any)[action](...args);
587
- } catch (error) {
588
- this.debug(`Error broadcasting ${action} to ${player.guildId}:`, error);
589
- }
590
- }
591
- }
592
- }
593
-
594
- /**
595
- * Like {@link broadcast} but awaits every return value (for async methods such as `play`).
596
- * Uses `Promise.allSettled` — failures are captured per guild, not thrown as a whole.
597
- */
598
- async broadcastAsync(action: string, ...args: any[]): Promise<PromiseSettledResult<unknown>[]> {
599
- const pending: Promise<unknown>[] = [];
600
- for (const player of this.players.values()) {
601
- const fn = (player as any)[action];
602
- if (typeof fn !== "function") continue;
603
- try {
604
- pending.push(Promise.resolve(fn.apply(player, args)));
605
- } catch (error) {
606
- pending.push(Promise.reject(error));
607
- }
608
- }
609
- return Promise.allSettled(pending);
610
- }
611
-
612
- /**
613
- * Broadcast a player method only to the given guild ids (players must already exist).
614
- */
615
- broadcastGuilds(guildIds: readonly string[], action: string, ...args: any[]): void {
616
- const wanted = new Set(guildIds);
617
- for (const player of this.players.values()) {
618
- if (!wanted.has(player.guildId)) continue;
619
- if (typeof (player as any)[action] === "function") {
620
- try {
621
- (player as any)[action](...args);
622
- } catch (error) {
623
- this.debug(`Error broadcasting ${action} to ${player.guildId}:`, error);
624
- }
625
- }
626
- }
627
- }
628
-
629
- /**
630
- * Global {@link TrackMiddleware} configured on this manager (applied before per-player middleware).
631
- */
632
- getTrackMiddlewareChain(): TrackMiddleware[] {
633
- return [...this.trackMiddlewareFromOptions];
634
- }
635
-
636
- /**
637
- * Mirror playback controls from a leader guild to follower guilds (each guild must already have a {@link Player} via
638
- * {@link create}). Followers receive the same track on `trackStart`, and pause/resume/stop/volume from the leader when
639
- * applicable.
640
- *
641
- * @returns Unsubscribe function — also runs when the leader player is destroyed.
642
- */
643
- subscribePlaybackMirror(options: PlaybackMirrorOptions): () => void {
644
- const leader = this.get(options.leaderGuildId);
645
- if (!leader) {
646
- throw new Error(`subscribePlaybackMirror: no player for leader guild ${options.leaderGuildId}`);
647
- }
648
-
649
- const followers = [...new Set(options.followerGuildIds)].filter((id) => id !== options.leaderGuildId);
650
- const mirrorUserId = options.mirrorUserId;
651
- const syncVolume = options.syncVolume ?? true;
652
- const forwardMode = options.forwardMode ?? true;
653
-
654
- const existing = this.playbackMirrorUnsubscribes.get(options.leaderGuildId);
655
- existing?.();
656
-
657
- const runFollowers = (fn: (p: Player) => void | Promise<void>) => {
658
- for (const gid of followers) {
659
- const fp = this.get(gid);
660
- if (!fp) {
661
- this.debug(`Playback mirror: no player for follower guild ${gid}`);
662
- continue;
663
- }
664
- Promise.resolve(fn(fp)).catch((e) => this.debug(`Playback mirror follower error (${gid}):`, e));
665
- }
666
- };
667
-
668
- const onTrackStart = async (track: Track) => {
669
- if (!forwardMode) {
670
- runFollowers(async (fp) => {
671
- fp.stop();
672
- await fp.play(track, mirrorUserId);
673
- });
674
- return;
675
- }
676
-
677
- runFollowers((fp) => {
678
- if (!fp.connection || !leader.connection) {
679
- this.debug(`Playback mirror forwardMode: missing connection for follower ${fp.guildId}`);
680
- return;
681
- }
682
-
683
- try {
684
- // sync state
685
- fp.queue.clear();
686
-
687
- // optional fake current track sync
688
- if (track) {
689
- fp.queue.setCurrentTrack(track);
690
- }
691
-
692
- // subscribe directly to leader player
693
- fp.subscribeTo(leader);
694
- fp.isPlaying = leader.isPlaying;
695
- fp.isPaused = leader.isPaused;
696
-
697
- fp.emit("trackStart", track);
698
- this.debug(`Playback mirror forwardMode subscribed ${fp.guildId} -> ${leader.guildId}`);
699
- } catch (e) {
700
- this.debug(`Playback mirror forwardMode error (${fp.guildId}):`, e);
701
- }
702
- });
703
- };
704
-
705
- const onPause = () => {
706
- runFollowers((fp) => {
707
- if (forwardMode) {
708
- fp.isPaused = true;
709
- fp.isPlaying = false;
710
- return;
711
- }
712
-
713
- fp.pause();
714
- });
715
- };
716
-
717
- const onResume = () => {
718
- runFollowers((fp) => {
719
- if (forwardMode) {
720
- fp.isPaused = false;
721
- fp.isPlaying = true;
722
- return;
723
- }
724
-
725
- fp.resume();
726
- });
727
- };
728
-
729
- const onStop = () => {
730
- runFollowers((fp) => {
731
- if (forwardMode) {
732
- try {
733
- fp.connection?.subscribe(fp.audioPlayer);
734
-
735
- fp.isPlaying = false;
736
- fp.isPaused = false;
737
-
738
- fp.audioPlayer.stop(true);
739
- } catch {}
740
- return;
741
- }
742
-
743
- fp.stop();
744
- });
745
- };
746
-
747
- const onVolume = (_oldVol: number, newVol: number) => {
748
- if (!syncVolume) return;
749
- runFollowers((fp) => {
750
- fp.setVolume(newVol);
751
- });
752
- };
753
-
754
- let closed = false;
755
- const unsubscribe = () => {
756
- if (closed) return;
757
- closed = true;
758
- leader.off("trackStart", onTrackStart);
759
- leader.off("playerPause", onPause);
760
- leader.off("playerResume", onResume);
761
- leader.off("playerStop", onStop);
762
- leader.off("volumeChange", onVolume);
763
- leader.off("playerDestroy", onLeaderDestroy);
764
- onStop();
765
- this.playbackMirrorUnsubscribes.delete(options.leaderGuildId);
766
- };
767
-
768
- const onLeaderDestroy = () => {
769
- unsubscribe();
770
- };
771
-
772
- leader.on("trackStart", onTrackStart);
773
- leader.on("playerPause", onPause);
774
- leader.on("playerResume", onResume);
775
- leader.on("playerStop", onStop);
776
- leader.on("volumeChange", onVolume);
777
- leader.on("playerDestroy", onLeaderDestroy);
778
- if (leader?.currentTrack) onTrackStart(leader.currentTrack);
779
- this.playbackMirrorUnsubscribes.set(options.leaderGuildId, unsubscribe);
780
- return unsubscribe;
781
- }
782
-
783
- /**
784
- * Destroy all players and clean up
785
- */
786
- destroy(): void {
787
- this.debug(`Destroying all players`);
788
-
789
- for (const unsub of this.playbackMirrorUnsubscribes.values()) {
790
- try {
791
- unsub();
792
- } catch {}
793
- }
794
- this.playbackMirrorUnsubscribes.clear();
795
-
796
- // Stop cleanup intervals
797
- if (this.cleanupInterval) {
798
- clearInterval(this.cleanupInterval);
799
- this.cleanupInterval = null;
800
- }
801
-
802
- if (this.statsInterval) {
803
- clearInterval(this.statsInterval);
804
- this.statsInterval = null;
805
- }
806
-
807
- // Destroy all players
808
- for (const player of this.players.values()) {
809
- player.destroy();
810
- }
811
-
812
- this.players.clear();
813
- this.searchCache.clear();
814
- this.removeAllListeners();
815
- this.debug(`PlayerManager destroyed`);
816
- }
817
-
818
- /**
819
- * Search using PluginManager without creating a Player.
820
- *
821
- * Uses the same search pipeline as Player.search():
822
- * - cache
823
- * - plugin deduplication
824
- * - plugin scoring/evaluation
825
- * - fallback handling
826
- *
827
- * @param {string} query
828
- * @param {string} requestedBy
829
- * @returns {Promise<SearchResult>}
830
- */
831
- async search(query: string, requestedBy: string): Promise<SearchResult> {
832
- this.debug(`Search called with query: ${query}, requestedBy: ${requestedBy}`);
833
-
834
- // Cache
835
- const cached = this.getCachedSearch(query);
836
- if (cached) {
837
- return cached;
838
- }
839
-
840
- try {
841
- const result = await this.pluginManager.search(query, requestedBy);
842
-
843
- if (!result || !Array.isArray(result.tracks) || result.tracks.length === 0) {
844
- throw new Error(`No results found for: ${query}`);
845
- }
846
-
847
- this.debug(`Plugin search returned ${result.tracks.length} tracks (score: ${result.score?.score ?? "unknown"}%)`);
848
-
849
- if (result.score) {
850
- this.debug(`Search evaluation - ${result.score.reason}`);
851
- }
852
-
853
- this.setCachedSearch(query, result);
854
-
855
- return result;
856
- } catch (error) {
857
- this.debug(`Search error:`, error);
858
- throw error as Error;
859
- }
860
- }
861
-
862
- /**
863
- * Clear search cache
864
- */
865
- clearSearchCache(): void {
866
- const size = this.searchCache.size;
867
- this.searchCache.clear();
868
- this.debug(`Cleared ${size} search cache entries`);
869
- }
870
-
871
- /**
872
- * Register a plugin after initialization
873
- *
874
- * @param {SourcePlugin} plugin - Plugin to register
875
- */
876
- registerPlugin(plugin: SourcePlugin): void {
877
- this.plugins.push(plugin);
878
- this.pluginManager.register(plugin);
879
-
880
- this.debug(`Registered plugin: ${plugin.name}`);
881
-
882
- for (const player of this.players.values()) {
883
- player.addPlugin(plugin);
884
- }
885
- }
886
-
887
- /**
888
- * Unregister a plugin
889
- *
890
- * @param {string} name - Plugin name to unregister
891
- * @returns {boolean} True if plugin was unregistered
892
- */
893
- unregisterPlugin(name: string): boolean {
894
- const index = this.plugins.findIndex((p) => p.name === name);
895
- if (index === -1) return false;
896
-
897
- this.plugins.splice(index, 1);
898
- this.pluginManager.unregister(name);
899
-
900
- this.debug(`Unregistered plugin: ${name}`);
901
-
902
- return true;
903
- }
904
-
905
- /**
906
- * Get all registered plugins
907
- */
908
- getPlugins(): SourcePlugin[] {
909
- return [...this.plugins];
910
- }
911
-
912
- /**
913
- * Register an extension after initialization
914
- *
915
- * @param {BaseExtension} extension - Extension to register
916
- */
917
- registerExtension(extension: BaseExtension): void {
918
- this.extensions.push(extension);
919
- this.debug(`Registered extension: ${extension.name}`);
920
-
921
- // Register extension with all existing players
922
- for (const player of this.players.values()) {
923
- player.attachExtension(extension);
924
- }
925
- }
926
-
927
- /**
928
- * Get manager configuration
929
- */
930
- getConfig(): object {
931
- return {
932
- extractorTimeout: this.extractorTimeout,
933
- autoCleanup: this.autoCleanup,
934
- cleanupTimeout: this.cleanupTimeout,
935
- enableSearchCache: this.enableSearchCache,
936
- pluginsCount: this.plugins.length,
937
- extensionsCount: this.extensions.length,
938
- playersCount: this.players.size,
939
- };
940
- }
941
- }
942
-
943
- /**
944
- * Get the global PlayerManager instance
945
- *
946
- * @returns {PlayerManager | null} Global instance or null
947
- */
948
- export function getInstance(): PlayerManager | null {
949
- const globalInst = getGlobalManager();
950
- if (!globalInst) {
951
- console.error("[PlayerManager] Global instance not found, make sure to initialize with new PlayerManager(options)");
952
- return null;
953
- }
954
- return globalInst;
955
- }
1
+ import { EventEmitter } from "events";
2
+ import { Player } from "./Player";
3
+ import {
4
+ PlaybackMode,
5
+ PlayerManagerOptions,
6
+ PlayerOptions,
7
+ type Track,
8
+ SourcePlugin,
9
+ SearchResult,
10
+ ManagerEvents,
11
+ PlayerStats,
12
+ type PlaybackMirrorOptions,
13
+ type TrackMiddleware,
14
+ normalizeTrackMiddleware,
15
+ } from "../types";
16
+ import type { BaseExtension } from "../extensions";
17
+ import { withTimeout } from "../utils/timeout";
18
+ import { PluginManager } from "../plugins";
19
+
20
+ const GLOBAL_MANAGER_KEY: symbol = Symbol.for("ziplayer.PlayerManager.instance");
21
+
22
+ export const getGlobalManager = (): PlayerManager | null => {
23
+ try {
24
+ const instance = (globalThis as any)[GLOBAL_MANAGER_KEY];
25
+ if (!instance) {
26
+ return null;
27
+ }
28
+ return instance as PlayerManager;
29
+ } catch (error) {
30
+ console.error("[PlayerManager] Error getting global instance:", error);
31
+ return null;
32
+ }
33
+ };
34
+
35
+ const setGlobalManager = (instance: PlayerManager): void => {
36
+ try {
37
+ (globalThis as any)[GLOBAL_MANAGER_KEY] = instance;
38
+ } catch (error) {
39
+ console.error("[PlayerManager] Error setting global instance:", error);
40
+ }
41
+ };
42
+
43
+ export declare interface PlayerManager {
44
+ on<K extends keyof ManagerEvents>(event: K, listener: (...args: ManagerEvents[K]) => void): this;
45
+ emit<K extends keyof ManagerEvents>(event: K, ...args: ManagerEvents[K]): boolean;
46
+ }
47
+
48
+ interface ManagerCacheEntry<T> {
49
+ data: T;
50
+ timestamp: number;
51
+ expiresAt: number;
52
+ }
53
+
54
+ /**
55
+ * The main class for managing players across multiple Discord guilds.
56
+ *
57
+ * @example
58
+ * // Basic setup with plugins and extensions
59
+ * const manager = new PlayerManager({
60
+ * plugins: [
61
+ * new YouTubePlugin(),
62
+ * new SoundCloudPlugin(),
63
+ * new SpotifyPlugin(),
64
+ * new TTSPlugin({ defaultLang: "en" })
65
+ * ],
66
+ * extensions: [
67
+ * new voiceExt(null, { lang: "en-US" }),
68
+ * new lavalinkExt(null, {
69
+ * nodes: [{ host: "localhost", port: 2333, password: "youshallnotpass" }]
70
+ * })
71
+ * ],
72
+ * extractorTimeout: 10000,
73
+ * autoCleanup: true,
74
+ * cleanupInterval: 60000
75
+ * });
76
+ *
77
+ * // Create a player for a guild
78
+ * const player = await manager.create(guildId, {
79
+ * tts: { interrupt: true, volume: 1 },
80
+ * leaveOnEnd: true,
81
+ * leaveTimeout: 30000
82
+ * });
83
+ *
84
+ * // Get existing player
85
+ * const existingPlayer = manager.get(guildId);
86
+ * if (existingPlayer) {
87
+ * await existingPlayer.play("Never Gonna Give You Up", userId);
88
+ * }
89
+ */
90
+ export class PlayerManager extends EventEmitter {
91
+ private static instance: PlayerManager | null = null;
92
+ private players: Map<string, Player> = new Map();
93
+ private searchCache: Map<string, ManagerCacheEntry<SearchResult>>;
94
+ private readonly SEARCH_CACHE_TTL = 60 * 1000; // 1 minute
95
+ private readonly MAX_CACHE_SIZE = 100;
96
+ private cleanupInterval: NodeJS.Timeout | null = null;
97
+ private statsInterval: NodeJS.Timeout | null = null;
98
+
99
+ static async default(opt?: PlayerOptions): Promise<Player> {
100
+ let globaldef = getGlobalManager();
101
+ if (!globaldef) {
102
+ globaldef = new PlayerManager({});
103
+ }
104
+ return await globaldef.create("default", opt);
105
+ }
106
+
107
+ private plugins: SourcePlugin[];
108
+ private pluginManager: PluginManager;
109
+ private extensions: any[];
110
+ private B_debug: boolean = false;
111
+ private extractorTimeout: number = 10000;
112
+ private autoCleanup: boolean = true;
113
+ private cleanupTimeout: number = 60000; // 1 minute
114
+ private enableSearchCache: boolean = true;
115
+ private trackMiddlewareFromOptions: TrackMiddleware[] = [];
116
+
117
+ private debug(message?: any, ...optionalParams: any[]): void {
118
+ if (this.listenerCount("debug") > 0) {
119
+ this.emit("debug", `[PlayerManager] ${message}`, ...optionalParams);
120
+ if (!this.B_debug) {
121
+ this.B_debug = true;
122
+ }
123
+ }
124
+ }
125
+
126
+ constructor(options: PlayerManagerOptions = {}) {
127
+ super();
128
+ this.plugins = [];
129
+ this.pluginManager = new PluginManager(null as any, this, {
130
+ extractorTimeout: this.extractorTimeout,
131
+ });
132
+ this.searchCache = new Map();
133
+
134
+ // Initialize plugins
135
+ const provided = options.plugins || [];
136
+ for (const p of provided as any[]) {
137
+ try {
138
+ let instance: SourcePlugin | null = null;
139
+
140
+ if (p && typeof p === "object") {
141
+ instance = p as SourcePlugin;
142
+ } else if (typeof p === "function") {
143
+ instance = new (p as any)();
144
+ }
145
+
146
+ if (instance) {
147
+ this.plugins.push(instance);
148
+ this.pluginManager.register(instance);
149
+ }
150
+ this.debug(`Registered plugin: ${p.name || "unnamed"}`);
151
+ } catch (e) {
152
+ this.debug(`Failed to init plugin:`, e);
153
+ }
154
+ }
155
+
156
+ this.extensions = options.extensions || [];
157
+ this.extractorTimeout = options.extractorTimeout ?? 10000;
158
+ this.autoCleanup = options.autoCleanup ?? true;
159
+ this.cleanupTimeout = options.cleanupInterval ?? 60000;
160
+ this.enableSearchCache = options.enableSearchCache ?? true;
161
+ this.trackMiddlewareFromOptions = normalizeTrackMiddleware(options.trackMiddleware);
162
+
163
+ // Setup auto cleanup
164
+ if (this.autoCleanup) {
165
+ this.startAutoCleanup();
166
+ }
167
+
168
+ // Setup stats collection (optional)
169
+ if (options.enableStatsCollection) {
170
+ this.startStatsCollection();
171
+ }
172
+
173
+ setGlobalManager(this);
174
+ this.debug(`Initialized with ${this.plugins.length} plugins, ${this.extensions.length} extensions`);
175
+ }
176
+
177
+ private resolveGuildId(guildOrId: string | { id: string }): string {
178
+ if (typeof guildOrId === "string") return guildOrId;
179
+ if (guildOrId && typeof guildOrId === "object" && "id" in guildOrId) return guildOrId.id;
180
+ throw new Error("Invalid guild or guildId provided.");
181
+ }
182
+
183
+ private getSearchCacheKey(query: string): string {
184
+ return query.toLowerCase().trim();
185
+ }
186
+
187
+ private getCachedSearch(query: string): SearchResult | null {
188
+ if (!this.enableSearchCache) return null;
189
+
190
+ const key = this.getSearchCacheKey(query);
191
+ const cached = this.searchCache.get(key);
192
+
193
+ if (cached && Date.now() < cached.expiresAt) {
194
+ this.debug(`[Cache] Search hit for: ${query}`);
195
+ return cached.data;
196
+ }
197
+
198
+ if (cached) {
199
+ this.searchCache.delete(key);
200
+ }
201
+
202
+ return null;
203
+ }
204
+
205
+ private setCachedSearch(query: string, result: SearchResult): void {
206
+ if (!this.enableSearchCache) return;
207
+
208
+ // Clean up old entries if cache is too large
209
+ if (this.searchCache.size >= this.MAX_CACHE_SIZE) {
210
+ const oldest = Array.from(this.searchCache.entries()).sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
211
+ if (oldest) this.searchCache.delete(oldest[0]);
212
+ }
213
+
214
+ const key = this.getSearchCacheKey(query);
215
+ this.searchCache.set(key, {
216
+ data: result,
217
+ timestamp: Date.now(),
218
+ expiresAt: Date.now() + this.SEARCH_CACHE_TTL,
219
+ });
220
+ this.debug(`[Cache] Search stored for: ${query}`);
221
+ }
222
+
223
+ private clearExpiredCache(): void {
224
+ const now = Date.now();
225
+ let expiredCount = 0;
226
+
227
+ for (const [key, entry] of this.searchCache) {
228
+ if (now >= entry.expiresAt) {
229
+ this.searchCache.delete(key);
230
+ expiredCount++;
231
+ }
232
+ }
233
+
234
+ if (expiredCount > 0) {
235
+ this.debug(`[Cache] Cleared ${expiredCount} expired search entries`);
236
+ }
237
+ }
238
+
239
+ private startAutoCleanup(): void {
240
+ if (this.cleanupInterval) {
241
+ clearInterval(this.cleanupInterval);
242
+ }
243
+
244
+ this.cleanupInterval = setInterval(() => {
245
+ this.cleanupInactivePlayers();
246
+ this.clearExpiredCache();
247
+ }, this.cleanupTimeout);
248
+
249
+ this.debug(`Auto-cleanup started with interval: ${this.cleanupTimeout}ms`);
250
+ }
251
+
252
+ private startStatsCollection(): void {
253
+ if (this.statsInterval) {
254
+ clearInterval(this.statsInterval);
255
+ }
256
+
257
+ this.statsInterval = setInterval(() => {
258
+ const stats = this.getStats();
259
+ this.emit("stats", stats);
260
+ }, 30000); // Every 30 seconds
261
+ }
262
+
263
+ private cleanupInactivePlayers(): void {
264
+ let cleanedCount = 0;
265
+
266
+ for (const [guildId, player] of this.players) {
267
+ // Clean up players that are not playing and not connected
268
+ if (!player.isPlaying && !player.connection && player.queue.isEmpty) {
269
+ const idleTime = Date.now() - (player as any)._lastActivity || Date.now();
270
+ if (idleTime > this.cleanupTimeout) {
271
+ this.debug(`Cleaning up inactive player for guild: ${guildId}`);
272
+ player.destroy();
273
+ this.players.delete(guildId);
274
+ cleanedCount++;
275
+ }
276
+ }
277
+ }
278
+
279
+ if (cleanedCount > 0) {
280
+ this.debug(`Cleaned up ${cleanedCount} inactive players`);
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Create a new player for a guild
286
+ *
287
+ * @param {string | {id: string}} guildOrId - Guild ID or guild object
288
+ * @param {PlayerOptions} options - Player configuration options
289
+ * @returns {Promise<Player>} The created player instance
290
+ */
291
+ async create(guildOrId: string | { id: string }, options?: PlayerOptions): Promise<Player> {
292
+ const guildId = this.resolveGuildId(guildOrId);
293
+
294
+ if (this.players.has(guildId)) {
295
+ this.debug(`Player already exists for guildId: ${guildId}, returning existing`);
296
+ return this.players.get(guildId)!;
297
+ }
298
+
299
+ this.debug(`Creating player for guildId: ${guildId}`);
300
+ const player = new Player(guildId, options, this);
301
+
302
+ // Add all registered plugins
303
+ this.plugins.forEach((plugin) => player.addPlugin(plugin));
304
+
305
+ // Activate extensions
306
+ let extsToActivate: any[] = [];
307
+ const optExts = (options as any)?.extensions as any[] | string[] | undefined;
308
+
309
+ if (Array.isArray(optExts)) {
310
+ if (optExts.length === 0) {
311
+ extsToActivate = [];
312
+ } else if (typeof optExts[0] === "string") {
313
+ const wanted = new Set(optExts as string[]);
314
+ extsToActivate = this.extensions.filter((ext) => {
315
+ const name = typeof ext === "function" ? ext.name : ext?.name;
316
+ return !!name && wanted.has(name);
317
+ });
318
+ } else {
319
+ extsToActivate = optExts;
320
+ }
321
+ } else {
322
+ // Use all extensions by default
323
+ extsToActivate = this.extensions;
324
+ }
325
+
326
+ for (const ext of extsToActivate) {
327
+ let instance = ext;
328
+ if (typeof ext === "function") {
329
+ try {
330
+ instance = new ext(player);
331
+ } catch (e) {
332
+ this.debug(`Extension constructor error for ${ext.name}:`, e);
333
+ continue;
334
+ }
335
+ }
336
+
337
+ if (instance && typeof instance === "object") {
338
+ const extInstance = instance as BaseExtension;
339
+ if ("player" in extInstance && !extInstance.player) extInstance.player = player;
340
+ player.attachExtension(extInstance);
341
+
342
+ if (typeof extInstance.active === "function") {
343
+ let activated: boolean | void = true;
344
+ try {
345
+ activated = await withTimeout(
346
+ Promise.resolve(extInstance.active({ manager: this, player })),
347
+ player.options.extractorTimeout ?? 15000,
348
+ `Extension ${extInstance?.name} activation timed out`,
349
+ );
350
+ this.debug(`Extension ${extInstance?.name} active check returned: ${activated}`);
351
+ } catch (e) {
352
+ activated = false;
353
+ this.debug(`Extension activation error for ${extInstance?.name}:`, e);
354
+ }
355
+
356
+ if (activated === false) {
357
+ player.detachExtension(extInstance);
358
+ continue;
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ // Forward all player events to manager
365
+ this.setupEventForwarding(player, guildId);
366
+
367
+ // Mark last activity
368
+ (player as any)._lastActivity = Date.now();
369
+
370
+ this.players.set(guildId, player);
371
+ this.debug(`Player created for guildId: ${guildId}`);
372
+ return player;
373
+ }
374
+
375
+ private setupEventForwarding(player: Player, guildId: string): void {
376
+ const forwardEvents = {
377
+ willPlay: "willPlay",
378
+ trackStart: "trackStart",
379
+ trackEnd: "trackEnd",
380
+ queueEnd: "queueEnd",
381
+ playerError: "playerError",
382
+ connectionError: "connectionError",
383
+ volumeChange: "volumeChange",
384
+ queueAdd: "queueAdd",
385
+ queueAddList: "queueAddList",
386
+ queueRemove: "queueRemove",
387
+ playerPause: "playerPause",
388
+ playerResume: "playerResume",
389
+ playerStop: "playerStop",
390
+ ttsStart: "ttsStart",
391
+ ttsEnd: "ttsEnd",
392
+ streamError: "streamError",
393
+ forwardModeStart: "forwardModeStart",
394
+ forwardModeEnd: "forwardModeEnd",
395
+ } as const satisfies Record<string, keyof ManagerEvents>;
396
+
397
+ for (const [sourceEvent, targetEvent] of Object.entries(forwardEvents) as [
398
+ keyof typeof forwardEvents,
399
+ keyof ManagerEvents,
400
+ ][]) {
401
+ player.on(sourceEvent, (...args: any[]) => {
402
+ if (sourceEvent === "trackStart") {
403
+ player._lastActivity = Date.now();
404
+ }
405
+
406
+ (this.emit as any)(targetEvent, player, ...args);
407
+ });
408
+ }
409
+
410
+ player.on("playerDestroy", () => {
411
+ this.emit("playerDestroy", player);
412
+
413
+ // Cleanup: unsubscribe all followers when leader is destroyed
414
+ if (player.forwardFollowers.size > 0) {
415
+ this.debug(`Leader ${guildId} destroyed, cleaning up ${player.forwardFollowers.size} followers`);
416
+ for (const follower of [...player.forwardFollowers]) {
417
+ try {
418
+ follower.unsubscribeForward("Leader destroyed");
419
+ } catch (err) {
420
+ this.debug(`Failed to unsubscribe follower ${follower.guildId}:`, err);
421
+ }
422
+ }
423
+ }
424
+
425
+ // Cleanup: if this player is a follower, unsubscribe from leader
426
+ if (player.playbackMode === PlaybackMode.FORWARD && player.forwardLeader) {
427
+ this.debug(`Follower ${guildId} destroyed, unsubscribing from leader ${player.forwardLeader.guildId}`);
428
+ player.unsubscribeForward("Follower destroyed");
429
+ }
430
+
431
+ this.players.delete(guildId);
432
+
433
+ this.debug(`Player destroyed for guildId: ${guildId}`);
434
+ });
435
+
436
+ player.on("debug", (...args) => {
437
+ if (this.listenerCount("debug") > 0) {
438
+ this.emit("debug", ...args);
439
+ }
440
+ });
441
+ }
442
+ /**
443
+ * Get an existing player for a guild
444
+ *
445
+ * @param {string | {id: string}} guildOrId - Guild ID or guild object
446
+ * @returns {Player | undefined} The player instance or undefined if not found
447
+ */
448
+ get(guildOrId: string | { id: string }): Player | undefined {
449
+ const guildId = this.resolveGuildId(guildOrId);
450
+ const player = this.players.get(guildId);
451
+ if (player) {
452
+ (player as any)._lastActivity = Date.now();
453
+ }
454
+ return player;
455
+ }
456
+
457
+ /**
458
+ * Get an existing player for a guild (alias for get)
459
+ */
460
+ getPlayer(guildOrId: string | { id: string }): Player | undefined {
461
+ return this.get(guildOrId);
462
+ }
463
+
464
+ /**
465
+ * Get all players
466
+ *
467
+ * @returns {Player[]} All player instances
468
+ */
469
+ getAll(): Player[] {
470
+ return Array.from(this.players.values());
471
+ }
472
+
473
+ /**
474
+ * Alias for getAll
475
+ */
476
+ getall(): Player[] {
477
+ return this.getAll();
478
+ }
479
+
480
+ /**
481
+ * Get players by filter
482
+ *
483
+ * @param {(player: Player) => boolean} filter - Filter function
484
+ * @returns {Player[]} Filtered player instances
485
+ */
486
+ getPlayersByFilter(filter: (player: Player) => boolean): Player[] {
487
+ return this.getAll().filter(filter);
488
+ }
489
+
490
+ /**
491
+ * Get players in a voice channel
492
+ *
493
+ * @param {string} channelId - Voice channel ID
494
+ * @returns {Player[]} Players in the channel
495
+ */
496
+ getPlayersInChannel(channelId: string): Player[] {
497
+ return this.getAll().filter((p) => p.connection?.joinConfig.channelId === channelId);
498
+ }
499
+
500
+ /**
501
+ * Destroy a player and clean up resources
502
+ *
503
+ * @param {string | {id: string}} guildOrId - Guild ID or guild object
504
+ * @returns {boolean} True if player was destroyed, false if not found
505
+ */
506
+ delete(guildOrId: string | { id: string }): boolean {
507
+ const guildId = this.resolveGuildId(guildOrId);
508
+ const player = this.players.get(guildId);
509
+
510
+ if (player) {
511
+ this.debug(`Deleting player for guildId: ${guildId}`);
512
+ player.destroy();
513
+ return true;
514
+ }
515
+ return false;
516
+ }
517
+
518
+ /**
519
+ * Destroy multiple players by filter
520
+ *
521
+ * @param {(player: Player) => boolean} filter - Filter function
522
+ * @returns {number} Number of players destroyed
523
+ */
524
+ deleteWhere(filter: (player: Player) => boolean): number {
525
+ const toDelete = this.getPlayersByFilter(filter);
526
+ let count = 0;
527
+
528
+ for (const player of toDelete) {
529
+ const guildId = player.guildId;
530
+ player.destroy();
531
+ this.players.delete(guildId);
532
+ count++;
533
+ }
534
+
535
+ if (count > 0) {
536
+ this.debug(`Deleted ${count} players by filter`);
537
+ }
538
+ return count;
539
+ }
540
+
541
+ /**
542
+ * Check if a player exists for a guild
543
+ *
544
+ * @param {string | {id: string}} guildOrId - Guild ID or guild object
545
+ * @returns {boolean} True if player exists
546
+ */
547
+ has(guildOrId: string | { id: string }): boolean {
548
+ const guildId = this.resolveGuildId(guildOrId);
549
+ return this.players.has(guildId);
550
+ }
551
+
552
+ /**
553
+ * Get number of players
554
+ */
555
+ get size(): number {
556
+ return this.players.size;
557
+ }
558
+
559
+ /**
560
+ * Check if debug is enabled
561
+ */
562
+ get debugEnabled(): boolean {
563
+ return this.B_debug;
564
+ }
565
+
566
+ /**
567
+ * Get manager statistics
568
+ *
569
+ * @returns {PlayerStats} Statistics about players
570
+ */
571
+ getStats(): PlayerStats {
572
+ let activePlayers = 0;
573
+ let pausedPlayers = 0;
574
+ let connectedPlayers = 0;
575
+ let totalTracksInQueue = 0;
576
+ let forwardHealthStatus = [];
577
+ let leader = 0;
578
+ let follower = 0;
579
+
580
+ for (const player of this.players.values()) {
581
+ if (player.isPlaying) activePlayers++;
582
+ if (player.isPaused) pausedPlayers++;
583
+ if (player.connection) connectedPlayers++;
584
+ totalTracksInQueue += player.queueSize;
585
+ const forwardStatus = player.getForwardHealthStatus();
586
+ if (forwardStatus.role === "leader") leader++;
587
+ if (forwardStatus.role === "follower") follower++;
588
+
589
+ forwardHealthStatus.push(forwardStatus);
590
+ }
591
+
592
+ return {
593
+ totalPlayers: this.players.size,
594
+ leader,
595
+ follower,
596
+ activePlayers,
597
+ pausedPlayers,
598
+ connectedPlayers,
599
+ totalTracksInQueue,
600
+ forwardHealthStatus,
601
+ };
602
+ }
603
+
604
+ /**
605
+ * Broadcast an action to all players
606
+ *
607
+ * @param {string} action - Action to perform
608
+ * @param {...any[]} args - Arguments for the action
609
+ * @example
610
+ * manager.broadcast("setVolume", 50);
611
+ * manager.broadcast("pause");
612
+ */
613
+ broadcast(action: string, ...args: any[]): void {
614
+ for (const player of this.players.values()) {
615
+ if (typeof (player as any)[action] === "function") {
616
+ try {
617
+ (player as any)[action](...args);
618
+ } catch (error) {
619
+ this.debug(`Error broadcasting ${action} to ${player.guildId}:`, error);
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Like {@link broadcast} but awaits every return value (for async methods such as `play`).
627
+ * Uses `Promise.allSettled` — failures are captured per guild, not thrown as a whole.
628
+ */
629
+ async broadcastAsync(action: string, ...args: any[]): Promise<PromiseSettledResult<unknown>[]> {
630
+ const pending: Promise<unknown>[] = [];
631
+ for (const player of this.players.values()) {
632
+ const fn = (player as any)[action];
633
+ if (typeof fn !== "function") continue;
634
+ try {
635
+ pending.push(Promise.resolve(fn.apply(player, args)));
636
+ } catch (error) {
637
+ pending.push(Promise.reject(error));
638
+ }
639
+ }
640
+ return Promise.allSettled(pending);
641
+ }
642
+
643
+ /**
644
+ * Broadcast a player method only to the given guild ids (players must already exist).
645
+ */
646
+ broadcastGuilds(guildIds: readonly string[], action: string, ...args: any[]): void {
647
+ const wanted = new Set(guildIds);
648
+ for (const player of this.players.values()) {
649
+ if (!wanted.has(player.guildId)) continue;
650
+ if (typeof (player as any)[action] === "function") {
651
+ try {
652
+ (player as any)[action](...args);
653
+ } catch (error) {
654
+ this.debug(`Error broadcasting ${action} to ${player.guildId}:`, error);
655
+ }
656
+ }
657
+ }
658
+ }
659
+
660
+ /**
661
+ * Global {@link TrackMiddleware} configured on this manager (applied before per-player middleware).
662
+ */
663
+ getTrackMiddlewareChain(): TrackMiddleware[] {
664
+ return [...this.trackMiddlewareFromOptions];
665
+ }
666
+
667
+ /**
668
+ * Mirror playback from one leader guild to multiple follower guilds.
669
+ *
670
+ * Followers directly subscribe to the leader player's audio pipeline,
671
+ * allowing multiple guilds to hear the same audio stream with extremely
672
+ * low CPU and bandwidth usage.
673
+ *
674
+ * Unlike traditional mirroring, followers do not create independent streams.
675
+ * Instead, their voice connections subscribe directly to the leader player's
676
+ * {@link audioPlayer}.
677
+ *
678
+ * ## Features
679
+ * - Shared playback pipeline
680
+ * - Followers may join at different times
681
+ * - Real-time track synchronization
682
+ * - Optional volume synchronization
683
+ * - Automatic cleanup on destroy
684
+ * - Low CPU / bandwidth usage
685
+ *
686
+ * ## Lifecycle
687
+ * - Destroying the leader automatically unsubscribes all followers.
688
+ * - Destroying a follower only removes that follower.
689
+ * - Followers may manually unsubscribe using {@link Player.unsubscribeForward}.
690
+ *
691
+ * ## Requirements
692
+ * - All guilds must already have active players.
693
+ * - All players must already be connected to voice channels.
694
+ *
695
+ * @param {PlaybackMirrorOptions} options Playback mirror configuration.
696
+ *
697
+ * @returns {() => void} Cleanup function that unsubscribes all followers.
698
+ *
699
+ * @example
700
+ * const stopMirror = manager.subscribeForwardMirror({
701
+ * leaderGuildId: "123",
702
+ * followerGuildIds: ["456", "789"],
703
+ * mirrorUserId: client.user.id,
704
+ * syncVolume: true,
705
+ * });
706
+ *
707
+ * // later
708
+ * stopMirror();
709
+ */
710
+
711
+ subscribeForwardMirror(options: PlaybackMirrorOptions): () => void {
712
+ const leader = this.get(options.leaderGuildId);
713
+
714
+ if (!leader) {
715
+ throw new Error(`subscribeForwardMirror: no player for leader guild ${options.leaderGuildId}`);
716
+ }
717
+ if (!leader.connection) {
718
+ throw new Error(`Leader player ${options.leaderGuildId} is not connected to a voice channel`);
719
+ }
720
+ const followers = [...new Set(options.followerGuildIds)].filter((id) => id !== options.leaderGuildId);
721
+
722
+ for (const gid of followers) {
723
+ const fp = this.get(gid);
724
+
725
+ if (!fp) {
726
+ this.debug(`Playback mirror: no player for follower guild ${gid}`);
727
+ continue;
728
+ }
729
+
730
+ if (!fp.connection) {
731
+ this.debug(`Playback mirror: follower ${gid} not connected to voice channel`);
732
+ continue;
733
+ }
734
+
735
+ fp.subscribeTo(leader, {
736
+ forwardMode: options.forwardMode,
737
+ });
738
+ }
739
+
740
+ return () => {
741
+ for (const gid of followers) {
742
+ const fp = this.get(gid);
743
+
744
+ try {
745
+ fp?.unsubscribeForward();
746
+ } catch {}
747
+ }
748
+ };
749
+ }
750
+
751
+ /**
752
+ * Destroy all players and clean up
753
+ */
754
+ destroy(): void {
755
+ this.debug(`Destroying all players`);
756
+
757
+ // Stop cleanup intervals
758
+ if (this.cleanupInterval) {
759
+ clearInterval(this.cleanupInterval);
760
+ this.cleanupInterval = null;
761
+ }
762
+
763
+ if (this.statsInterval) {
764
+ clearInterval(this.statsInterval);
765
+ this.statsInterval = null;
766
+ }
767
+
768
+ // Destroy all players
769
+ for (const player of this.players.values()) {
770
+ player.destroy();
771
+ }
772
+
773
+ this.players.clear();
774
+ this.searchCache.clear();
775
+ this.removeAllListeners();
776
+ this.debug(`PlayerManager destroyed`);
777
+ }
778
+
779
+ /**
780
+ * Search using PluginManager without creating a Player.
781
+ *
782
+ * Uses the same search pipeline as Player.search():
783
+ * - cache
784
+ * - plugin deduplication
785
+ * - plugin scoring/evaluation
786
+ * - fallback handling
787
+ *
788
+ * @param {string} query
789
+ * @param {string} requestedBy
790
+ * @returns {Promise<SearchResult>}
791
+ */
792
+ async search(query: string, requestedBy: string): Promise<SearchResult> {
793
+ this.debug(`Search called with query: ${query}, requestedBy: ${requestedBy}`);
794
+
795
+ // Cache
796
+ const cached = this.getCachedSearch(query);
797
+ if (cached) {
798
+ return cached;
799
+ }
800
+
801
+ try {
802
+ const result = await this.pluginManager.search(query, requestedBy);
803
+
804
+ if (!result || !Array.isArray(result.tracks) || result.tracks.length === 0) {
805
+ throw new Error(`No results found for: ${query}`);
806
+ }
807
+
808
+ this.debug(`Plugin search returned ${result.tracks.length} tracks (score: ${result.score?.score ?? "unknown"}%)`);
809
+
810
+ if (result.score) {
811
+ this.debug(`Search evaluation - ${result.score.reason}`);
812
+ }
813
+
814
+ this.setCachedSearch(query, result);
815
+
816
+ return result;
817
+ } catch (error) {
818
+ this.debug(`Search error:`, error);
819
+ throw error as Error;
820
+ }
821
+ }
822
+
823
+ /**
824
+ * Clear search cache
825
+ */
826
+ clearSearchCache(): void {
827
+ const size = this.searchCache.size;
828
+ this.searchCache.clear();
829
+ this.debug(`Cleared ${size} search cache entries`);
830
+ }
831
+
832
+ /**
833
+ * Register a plugin after initialization
834
+ *
835
+ * @param {SourcePlugin} plugin - Plugin to register
836
+ */
837
+ registerPlugin(plugin: SourcePlugin): void {
838
+ this.plugins.push(plugin);
839
+ this.pluginManager.register(plugin);
840
+
841
+ this.debug(`Registered plugin: ${plugin.name}`);
842
+
843
+ for (const player of this.players.values()) {
844
+ player.addPlugin(plugin);
845
+ }
846
+ }
847
+
848
+ /**
849
+ * Unregister a plugin
850
+ *
851
+ * @param {string} name - Plugin name to unregister
852
+ * @returns {boolean} True if plugin was unregistered
853
+ */
854
+ unregisterPlugin(name: string): boolean {
855
+ const index = this.plugins.findIndex((p) => p.name === name);
856
+ if (index === -1) return false;
857
+
858
+ this.plugins.splice(index, 1);
859
+ this.pluginManager.unregister(name);
860
+
861
+ this.debug(`Unregistered plugin: ${name}`);
862
+
863
+ return true;
864
+ }
865
+
866
+ /**
867
+ * Get all registered plugins
868
+ */
869
+ getPlugins(): SourcePlugin[] {
870
+ return [...this.plugins];
871
+ }
872
+
873
+ /**
874
+ * Register an extension after initialization
875
+ *
876
+ * @param {BaseExtension} extension - Extension to register
877
+ */
878
+ registerExtension(extension: BaseExtension): void {
879
+ this.extensions.push(extension);
880
+ this.debug(`Registered extension: ${extension.name}`);
881
+
882
+ // Register extension with all existing players
883
+ for (const player of this.players.values()) {
884
+ player.attachExtension(extension);
885
+ }
886
+ }
887
+
888
+ /**
889
+ * Get manager configuration
890
+ */
891
+ getConfig(): object {
892
+ return {
893
+ extractorTimeout: this.extractorTimeout,
894
+ autoCleanup: this.autoCleanup,
895
+ cleanupTimeout: this.cleanupTimeout,
896
+ enableSearchCache: this.enableSearchCache,
897
+ pluginsCount: this.plugins.length,
898
+ extensionsCount: this.extensions.length,
899
+ playersCount: this.players.size,
900
+ };
901
+ }
902
+ }
903
+
904
+ /**
905
+ * Get the global PlayerManager instance
906
+ *
907
+ * @returns {PlayerManager | null} Global instance or null
908
+ */
909
+ export function getInstance(): PlayerManager | null {
910
+ const globalInst = getGlobalManager();
911
+ if (!globalInst) {
912
+ console.error("[PlayerManager] Global instance not found, make sure to initialize with new PlayerManager(options)");
913
+ return null;
914
+ }
915
+ return globalInst;
916
+ }