streamify-audio 2.0.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.
@@ -0,0 +1,658 @@
1
+ const { EventEmitter } = require('events');
2
+ const {
3
+ joinVoiceChannel,
4
+ createAudioPlayer,
5
+ AudioPlayerStatus,
6
+ VoiceConnectionStatus,
7
+ entersState,
8
+ NoSubscriberBehavior
9
+ } = require('@discordjs/voice');
10
+ const Queue = require('./Queue');
11
+ const { createStream } = require('./Stream');
12
+ const log = require('../utils/logger');
13
+
14
+ class Player extends EventEmitter {
15
+ constructor(manager, options) {
16
+ super();
17
+ this.manager = manager;
18
+ this.guildId = options.guildId;
19
+ this.voiceChannelId = options.voiceChannelId;
20
+ this.textChannelId = options.textChannelId;
21
+ this.config = manager.config;
22
+
23
+ this.connection = null;
24
+ this.audioPlayer = null;
25
+ this.queue = new Queue({ maxPreviousTracks: options.maxPreviousTracks || 25 });
26
+ this.stream = null;
27
+
28
+ this._volume = options.volume || manager.config.defaultVolume || 80;
29
+ this._filters = {};
30
+ this._playing = false;
31
+ this._paused = false;
32
+ this._positionTimestamp = 0;
33
+ this._positionMs = 0;
34
+ this._destroyed = false;
35
+
36
+ this._prefetchedStream = null;
37
+ this._prefetchedTrack = null;
38
+ this._prefetching = false;
39
+ this._changingStream = false;
40
+
41
+ this.autoLeave = {
42
+ enabled: options.autoLeave?.enabled ?? true,
43
+ emptyDelay: options.autoLeave?.emptyDelay ?? 30000,
44
+ inactivityTimeout: options.autoLeave?.inactivityTimeout ?? 300000
45
+ };
46
+
47
+ this.autoPause = {
48
+ enabled: options.autoPause?.enabled ?? true,
49
+ minUsers: options.autoPause?.minUsers ?? 1
50
+ };
51
+
52
+ this.autoplay = {
53
+ enabled: options.autoplay?.enabled ?? false,
54
+ maxTracks: options.autoplay?.maxTracks ?? 5
55
+ };
56
+
57
+ this._emptyTimeout = null;
58
+ this._inactivityTimeout = null;
59
+ this._lastActivity = Date.now();
60
+ this._autoPaused = false;
61
+ }
62
+
63
+ setAutoPause(enabled) {
64
+ this.autoPause.enabled = enabled;
65
+ return this.autoPause.enabled;
66
+ }
67
+
68
+ _startEmptyTimeout() {
69
+ this._cancelEmptyTimeout();
70
+ if (!this.autoLeave.enabled) return;
71
+
72
+ log.info('PLAYER', `Channel empty, will leave in ${this.autoLeave.emptyDelay / 1000}s`);
73
+ this._emptyTimeout = setTimeout(() => {
74
+ log.info('PLAYER', 'Leaving due to empty channel');
75
+ this.destroy();
76
+ }, this.autoLeave.emptyDelay);
77
+ }
78
+
79
+ _cancelEmptyTimeout() {
80
+ if (this._emptyTimeout) {
81
+ clearTimeout(this._emptyTimeout);
82
+ this._emptyTimeout = null;
83
+ }
84
+ }
85
+
86
+ _resetInactivityTimeout() {
87
+ this._lastActivity = Date.now();
88
+ if (this._inactivityTimeout) {
89
+ clearTimeout(this._inactivityTimeout);
90
+ }
91
+ if (!this.autoLeave.enabled || this.autoLeave.inactivityTimeout <= 0) return;
92
+
93
+ this._inactivityTimeout = setTimeout(() => {
94
+ if (!this._playing) {
95
+ log.info('PLAYER', 'Leaving due to inactivity');
96
+ this.destroy();
97
+ }
98
+ }, this.autoLeave.inactivityTimeout);
99
+ }
100
+
101
+ setAutoplay(enabled) {
102
+ this.autoplay.enabled = enabled;
103
+ return this.autoplay.enabled;
104
+ }
105
+
106
+ get connected() {
107
+ return this.connection?.state?.status === VoiceConnectionStatus.Ready;
108
+ }
109
+
110
+ get playing() {
111
+ return this._playing && !this._paused;
112
+ }
113
+
114
+ get paused() {
115
+ return this._paused;
116
+ }
117
+
118
+ get volume() {
119
+ return this._volume;
120
+ }
121
+
122
+ get filters() {
123
+ return { ...this._filters };
124
+ }
125
+
126
+ get position() {
127
+ if (!this._playing) return 0;
128
+ if (this._paused) return this._positionMs;
129
+ return this._positionMs + (Date.now() - this._positionTimestamp);
130
+ }
131
+
132
+ async connect() {
133
+ if (this._destroyed) {
134
+ throw new Error('Player has been destroyed');
135
+ }
136
+
137
+ if (this.connected) {
138
+ return this;
139
+ }
140
+
141
+ const guild = this.manager.client.guilds.cache.get(this.guildId);
142
+ if (!guild) {
143
+ throw new Error('Guild not found');
144
+ }
145
+
146
+ log.info('PLAYER', `Connecting to voice channel ${this.voiceChannelId}`);
147
+
148
+ this.connection = joinVoiceChannel({
149
+ channelId: this.voiceChannelId,
150
+ guildId: this.guildId,
151
+ adapterCreator: guild.voiceAdapterCreator,
152
+ selfDeaf: true,
153
+ selfMute: false
154
+ });
155
+
156
+ this.audioPlayer = createAudioPlayer({
157
+ behaviors: {
158
+ noSubscriber: NoSubscriberBehavior.Play
159
+ }
160
+ });
161
+
162
+ this.connection.subscribe(this.audioPlayer);
163
+ this._setupListeners();
164
+
165
+ try {
166
+ await entersState(this.connection, VoiceConnectionStatus.Ready, 10000);
167
+ log.info('PLAYER', `Connected to voice channel ${this.voiceChannelId}`);
168
+ return this;
169
+ } catch (error) {
170
+ this.destroy();
171
+ throw new Error('Failed to connect to voice channel');
172
+ }
173
+ }
174
+
175
+ _setupListeners() {
176
+ this.audioPlayer.on(AudioPlayerStatus.Idle, async () => {
177
+ if (!this._playing || this._changingStream || this._paused) return;
178
+
179
+ const track = this.queue.current;
180
+ this._playing = false;
181
+ this._positionMs = 0;
182
+
183
+ if (this.stream) {
184
+ this.stream.destroy();
185
+ this.stream = null;
186
+ }
187
+
188
+ this.emit('trackEnd', track, 'finished');
189
+
190
+ const next = this.queue.shift();
191
+ if (next) {
192
+ this._playTrack(next);
193
+ } else {
194
+ if (this.autoplay.enabled && track) {
195
+ await this._handleAutoplay(track);
196
+ } else {
197
+ this.emit('queueEnd');
198
+ this._resetInactivityTimeout();
199
+ }
200
+ }
201
+ });
202
+
203
+ this.audioPlayer.on(AudioPlayerStatus.Playing, () => {
204
+ this._positionTimestamp = Date.now();
205
+ });
206
+
207
+ this.audioPlayer.on('error', (error) => {
208
+ if (error.message === 'Premature close' || error.message.includes('EPIPE')) {
209
+ return;
210
+ }
211
+ log.error('PLAYER', `Audio player error: ${error.message}`);
212
+ const track = this.queue.current;
213
+
214
+ if (this.stream) {
215
+ this.stream.destroy();
216
+ this.stream = null;
217
+ }
218
+
219
+ this._playing = false;
220
+ this.emit('trackError', track, error);
221
+
222
+ const next = this.queue.shift();
223
+ if (next) {
224
+ this._playTrack(next);
225
+ } else {
226
+ this.emit('queueEnd');
227
+ }
228
+ });
229
+
230
+ this.connection.on(VoiceConnectionStatus.Disconnected, async () => {
231
+ try {
232
+ await Promise.race([
233
+ entersState(this.connection, VoiceConnectionStatus.Signalling, 5000),
234
+ entersState(this.connection, VoiceConnectionStatus.Connecting, 5000)
235
+ ]);
236
+ } catch {
237
+ log.info('PLAYER', `Disconnected from voice channel ${this.voiceChannelId}`);
238
+ this.destroy();
239
+ }
240
+ });
241
+
242
+ this.connection.on(VoiceConnectionStatus.Destroyed, () => {
243
+ this._destroyed = true;
244
+ });
245
+ }
246
+
247
+ async play(track) {
248
+ if (this._destroyed) {
249
+ throw new Error('Player has been destroyed');
250
+ }
251
+
252
+ if (!this.connected) {
253
+ await this.connect();
254
+ }
255
+
256
+ if (this.queue.current) {
257
+ this.queue.add(track, 0);
258
+ return this.skip();
259
+ }
260
+
261
+ this.queue.setCurrent(track);
262
+ return this._playTrack(track);
263
+ }
264
+
265
+ async _playTrack(track) {
266
+ if (!track) return;
267
+
268
+ log.info('PLAYER', `Playing: ${track.title} (${track.id})`);
269
+ this.emit('trackStart', track);
270
+
271
+ try {
272
+ const filtersWithVolume = {
273
+ ...this._filters,
274
+ volume: this._volume
275
+ };
276
+
277
+ if (this._prefetchedTrack?.id === track.id && this._prefetchedStream) {
278
+ log.info('PLAYER', `Using prefetched stream for ${track.id}`);
279
+ this.stream = this._prefetchedStream;
280
+ this._prefetchedStream = null;
281
+ this._prefetchedTrack = null;
282
+ } else {
283
+ this._clearPrefetch();
284
+ this.stream = createStream(track, filtersWithVolume, this.config);
285
+ }
286
+
287
+ const resource = await this.stream.create();
288
+
289
+ this.audioPlayer.play(resource);
290
+ this._playing = true;
291
+ this._paused = false;
292
+ this._positionMs = 0;
293
+ this._positionTimestamp = Date.now();
294
+
295
+ this._prefetchNext();
296
+
297
+ return track;
298
+ } catch (error) {
299
+ log.error('PLAYER', `Failed to play track: ${error.message}`);
300
+ this.emit('trackError', track, error);
301
+
302
+ const next = this.queue.shift();
303
+ if (next) {
304
+ return this._playTrack(next);
305
+ } else {
306
+ this.emit('queueEnd');
307
+ }
308
+ }
309
+ }
310
+
311
+ async _prefetchNext() {
312
+ if (this._prefetching || this.queue.tracks.length === 0) return;
313
+
314
+ const nextTrack = this.queue.tracks[0];
315
+ if (!nextTrack || this._prefetchedTrack?.id === nextTrack.id) return;
316
+
317
+ this._prefetching = true;
318
+ this._clearPrefetch();
319
+
320
+ log.info('PLAYER', `Prefetching: ${nextTrack.title} (${nextTrack.id})`);
321
+
322
+ try {
323
+ const filtersWithVolume = {
324
+ ...this._filters,
325
+ volume: this._volume
326
+ };
327
+
328
+ this._prefetchedTrack = nextTrack;
329
+ this._prefetchedStream = createStream(nextTrack, filtersWithVolume, this.config);
330
+ await this._prefetchedStream.create();
331
+
332
+ log.info('PLAYER', `Prefetch ready: ${nextTrack.id}`);
333
+ } catch (error) {
334
+ log.debug('PLAYER', `Prefetch failed: ${error.message}`);
335
+ this._clearPrefetch();
336
+ } finally {
337
+ this._prefetching = false;
338
+ }
339
+ }
340
+
341
+ _clearPrefetch() {
342
+ if (this._prefetchedStream) {
343
+ this._prefetchedStream.destroy();
344
+ this._prefetchedStream = null;
345
+ }
346
+ this._prefetchedTrack = null;
347
+ }
348
+
349
+ async _handleAutoplay(lastTrack) {
350
+ log.info('PLAYER', `Autoplay: fetching related tracks for ${lastTrack.title}`);
351
+ this.emit('autoplayStart', lastTrack);
352
+
353
+ try {
354
+ const result = await this.manager.getRelated(lastTrack, this.autoplay.maxTracks);
355
+
356
+ if (result.tracks.length === 0) {
357
+ log.info('PLAYER', 'Autoplay: no related tracks found');
358
+ this.emit('queueEnd');
359
+ this._resetInactivityTimeout();
360
+ return;
361
+ }
362
+
363
+ const track = result.tracks[0];
364
+ log.info('PLAYER', `Autoplay: playing ${track.title}`);
365
+
366
+ if (result.tracks.length > 1) {
367
+ this.queue.addMany(result.tracks.slice(1));
368
+ }
369
+
370
+ if (track.source === 'spotify') {
371
+ const spotify = require('../providers/spotify');
372
+ track._resolvedId = await spotify.resolveToYouTube(track.id, this.config);
373
+ }
374
+
375
+ this.queue.setCurrent(track);
376
+ this.emit('autoplayAdd', result.tracks);
377
+ await this._playTrack(track);
378
+ } catch (error) {
379
+ log.error('PLAYER', `Autoplay error: ${error.message}`);
380
+ this.emit('queueEnd');
381
+ this._resetInactivityTimeout();
382
+ }
383
+ }
384
+
385
+ pause(destroyStream = true) {
386
+ if (!this._playing || this._paused) return false;
387
+
388
+ this._positionMs = this.position;
389
+ this._paused = true;
390
+
391
+ if (destroyStream && this.stream) {
392
+ this._clearPrefetch();
393
+ this.stream.destroy();
394
+ this.stream = null;
395
+ }
396
+
397
+ this.audioPlayer.stop(true);
398
+ log.info('PLAYER', `Paused at ${Math.floor(this._positionMs / 1000)}s (stream destroyed)`);
399
+
400
+ return true;
401
+ }
402
+
403
+ async resume() {
404
+ if (!this._playing || !this._paused) return false;
405
+
406
+ if (!this.stream && this.queue.current) {
407
+ log.info('PLAYER', `Resuming from ${Math.floor(this._positionMs / 1000)}s (recreating stream)`);
408
+
409
+ this._changingStream = true;
410
+ const track = this.queue.current;
411
+ const filtersWithVolume = {
412
+ ...this._filters,
413
+ volume: this._volume
414
+ };
415
+
416
+ try {
417
+ this.stream = createStream(track, filtersWithVolume, this.config);
418
+ const resource = await this.stream.create(this._positionMs);
419
+
420
+ this.audioPlayer.play(resource);
421
+ this._paused = false;
422
+ this._positionTimestamp = Date.now();
423
+
424
+ this._prefetchNext();
425
+ } catch (error) {
426
+ log.error('PLAYER', `Resume failed: ${error.message}`);
427
+ this._paused = false;
428
+ this._playing = false;
429
+ this.emit('trackError', track, error);
430
+ return false;
431
+ } finally {
432
+ this._changingStream = false;
433
+ }
434
+ } else {
435
+ this.audioPlayer.unpause();
436
+ this._paused = false;
437
+ this._positionTimestamp = Date.now();
438
+ }
439
+
440
+ return true;
441
+ }
442
+
443
+ async skip() {
444
+ if (!this._playing && this.queue.isEmpty) {
445
+ return null;
446
+ }
447
+
448
+ if (this.stream) {
449
+ this.stream.destroy();
450
+ this.stream = null;
451
+ }
452
+
453
+ this.audioPlayer.stop();
454
+ return this.queue.current;
455
+ }
456
+
457
+ async previous() {
458
+ this._clearPrefetch();
459
+ const prev = this.queue.unshift();
460
+ if (!prev) return null;
461
+
462
+ if (this.stream) {
463
+ this.stream.destroy();
464
+ this.stream = null;
465
+ }
466
+
467
+ this.audioPlayer.stop();
468
+ return this._playTrack(prev);
469
+ }
470
+
471
+ stop() {
472
+ this._clearPrefetch();
473
+
474
+ if (this.stream) {
475
+ this.stream.destroy();
476
+ this.stream = null;
477
+ }
478
+
479
+ this.audioPlayer.stop();
480
+ this._playing = false;
481
+ this._paused = false;
482
+ this._positionMs = 0;
483
+ this.queue.clear();
484
+ this.queue.setCurrent(null);
485
+ return true;
486
+ }
487
+
488
+ setVolume(volume) {
489
+ this._volume = Math.max(0, Math.min(200, volume));
490
+ return this._volume;
491
+ }
492
+
493
+ async seek(positionMs) {
494
+ if (!this._playing || !this.queue.current) return false;
495
+
496
+ this._changingStream = true;
497
+
498
+ const track = this.queue.current;
499
+ const filtersWithVolume = {
500
+ ...this._filters,
501
+ volume: this._volume
502
+ };
503
+
504
+ if (this.stream) {
505
+ this.stream.destroy();
506
+ }
507
+
508
+ try {
509
+ this.stream = createStream(track, filtersWithVolume, this.config);
510
+ const resource = await this.stream.create(positionMs);
511
+
512
+ this.audioPlayer.play(resource);
513
+ this._positionMs = positionMs;
514
+ this._positionTimestamp = Date.now();
515
+ } finally {
516
+ this._changingStream = false;
517
+ }
518
+
519
+ return true;
520
+ }
521
+
522
+ setLoop(mode) {
523
+ return this.queue.setRepeatMode(mode);
524
+ }
525
+
526
+ async setFilter(name, value) {
527
+ this._filters[name] = value;
528
+
529
+ if (this._playing && this.queue.current) {
530
+ this._changingStream = true;
531
+
532
+ const currentPos = this.position;
533
+ const track = this.queue.current;
534
+ const filtersWithVolume = {
535
+ ...this._filters,
536
+ volume: this._volume
537
+ };
538
+
539
+ if (this.stream) {
540
+ this.stream.destroy();
541
+ }
542
+
543
+ try {
544
+ this.stream = createStream(track, filtersWithVolume, this.config);
545
+ const resource = await this.stream.create(currentPos);
546
+
547
+ this.audioPlayer.play(resource);
548
+ this._positionMs = currentPos;
549
+ this._positionTimestamp = Date.now();
550
+ } finally {
551
+ this._changingStream = false;
552
+ }
553
+ }
554
+
555
+ return true;
556
+ }
557
+
558
+ async clearFilters() {
559
+ this._filters = {};
560
+ if (this._playing && this.queue.current) {
561
+ return this.setFilter('_trigger', null);
562
+ }
563
+ return true;
564
+ }
565
+
566
+ async setEQ(bands) {
567
+ if (!Array.isArray(bands) || bands.length !== 15) {
568
+ throw new Error('EQ must be an array of 15 band gains (-0.25 to 1.0)');
569
+ }
570
+ return this.setFilter('equalizer', bands);
571
+ }
572
+
573
+ async setPreset(presetName) {
574
+ const { PRESETS } = require('../filters/ffmpeg');
575
+ if (!PRESETS[presetName]) {
576
+ throw new Error(`Unknown preset: ${presetName}. Available: ${Object.keys(PRESETS).join(', ')}`);
577
+ }
578
+ delete this._filters.equalizer;
579
+ return this.setFilter('preset', presetName);
580
+ }
581
+
582
+ async clearEQ() {
583
+ delete this._filters.equalizer;
584
+ delete this._filters.preset;
585
+ if (this._playing && this.queue.current) {
586
+ return this.setFilter('_trigger', null);
587
+ }
588
+ return true;
589
+ }
590
+
591
+ getPresets() {
592
+ const { PRESETS } = require('../filters/ffmpeg');
593
+ return Object.keys(PRESETS);
594
+ }
595
+
596
+ disconnect() {
597
+ if (this.connection) {
598
+ this.connection.destroy();
599
+ }
600
+ return true;
601
+ }
602
+
603
+ destroy() {
604
+ if (this._destroyed) return;
605
+ this._destroyed = true;
606
+
607
+ log.info('PLAYER', `Destroying player for guild ${this.guildId}`);
608
+
609
+ this._cancelEmptyTimeout();
610
+ if (this._inactivityTimeout) {
611
+ clearTimeout(this._inactivityTimeout);
612
+ this._inactivityTimeout = null;
613
+ }
614
+
615
+ this._clearPrefetch();
616
+
617
+ if (this.stream) {
618
+ this.stream.destroy();
619
+ this.stream = null;
620
+ }
621
+
622
+ if (this.audioPlayer) {
623
+ this.audioPlayer.stop(true);
624
+ }
625
+
626
+ if (this.connection) {
627
+ this.connection.destroy();
628
+ }
629
+
630
+ this._playing = false;
631
+ this._paused = false;
632
+ this.queue.clear();
633
+ this.queue.setCurrent(null);
634
+
635
+ this.emit('destroy');
636
+ this.removeAllListeners();
637
+ }
638
+
639
+ toJSON() {
640
+ return {
641
+ guildId: this.guildId,
642
+ voiceChannelId: this.voiceChannelId,
643
+ textChannelId: this.textChannelId,
644
+ connected: this.connected,
645
+ playing: this.playing,
646
+ paused: this.paused,
647
+ volume: this.volume,
648
+ position: this.position,
649
+ filters: this.filters,
650
+ queue: this.queue.toJSON(),
651
+ autoplay: this.autoplay,
652
+ autoPause: this.autoPause,
653
+ autoLeave: this.autoLeave
654
+ };
655
+ }
656
+ }
657
+
658
+ module.exports = Player;