djs-selfbot-v13 3.7.32 → 3.7.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -1
- package/src/client/Client.js +68 -1
- package/src/client/voice/ClientVoiceManager.js +95 -17
- package/src/client/voice/StreamSession.js +193 -0
- package/src/client/voice/VoiceConnection.js +266 -19
- package/src/client/voice/WebRtcStreamSession.js +191 -0
- package/src/client/voice/dispatcher/AnnexBDispatcher.js +64 -11
- package/src/client/voice/dispatcher/BaseDispatcher.js +13 -9
- package/src/client/voice/dispatcher/VideoDispatcher.js +33 -0
- package/src/client/voice/networking/DAVESession.js +234 -0
- package/src/client/voice/networking/VoiceWebSocket.js +240 -24
- package/src/client/voice/player/MediaPlayer.js +3 -1
- package/src/client/voice/player/processing/AnnexBBitstreamReaderWriter.js +137 -0
- package/src/client/voice/player/processing/SPSVUIRewriter.js +203 -0
- package/src/client/voice/receiver/PacketHandler.js +11 -4
- package/src/errors/Messages.js +8 -1
- package/src/util/Constants.js +12 -0
- package/typings/index.d.ts +36 -0
|
@@ -16,6 +16,14 @@ const { Opcodes, VoiceOpcodes, VoiceStatus, Events } = require('../../util/Const
|
|
|
16
16
|
const Speaking = require('../../util/Speaking');
|
|
17
17
|
const Util = require('../../util/Util');
|
|
18
18
|
|
|
19
|
+
function normalizeVoiceEndpoint(endpoint, { preservePort = false } = {}) {
|
|
20
|
+
const match = endpoint?.match(/^([^:]+)(?::(\d+))?/);
|
|
21
|
+
if (!match) return endpoint;
|
|
22
|
+
const [, host, port] = match;
|
|
23
|
+
if (!preservePort || !port || port === '443' || port === '80') return host;
|
|
24
|
+
return `${host}:${port}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
// Workaround for Discord now requiring silence to be sent before being able to receive audio
|
|
20
28
|
class SingleSilence extends Silence {
|
|
21
29
|
_read() {
|
|
@@ -166,6 +174,18 @@ class VoiceConnection extends EventEmitter {
|
|
|
166
174
|
* @type {Collection<Snowflake, StreamConnectionReadonly>}
|
|
167
175
|
*/
|
|
168
176
|
this.streamWatchConnection = new Collection();
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* DAVE end-to-end encryption session
|
|
180
|
+
* @type {?DAVESession}
|
|
181
|
+
*/
|
|
182
|
+
this.dave = null;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Connected client user IDs for DAVE MLS
|
|
186
|
+
* @type {Set<Snowflake>}
|
|
187
|
+
*/
|
|
188
|
+
this.connectedClients = new Set();
|
|
169
189
|
}
|
|
170
190
|
|
|
171
191
|
/**
|
|
@@ -228,6 +248,20 @@ class VoiceConnection extends EventEmitter {
|
|
|
228
248
|
return this;
|
|
229
249
|
}
|
|
230
250
|
|
|
251
|
+
/**
|
|
252
|
+
* SSRC values assigned by the voice server.
|
|
253
|
+
* @returns {{ audioSsrc: number, videoSsrc: number, rtxSsrc: number }}
|
|
254
|
+
*/
|
|
255
|
+
getStreamSsrcs() {
|
|
256
|
+
const stream = this.authentication.streams?.[0];
|
|
257
|
+
const audioSsrc = this.authentication.ssrc;
|
|
258
|
+
return {
|
|
259
|
+
audioSsrc,
|
|
260
|
+
videoSsrc: stream?.ssrc ?? audioSsrc + 1,
|
|
261
|
+
rtxSsrc: stream?.rtx_ssrc ?? audioSsrc + 2,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
231
265
|
/**
|
|
232
266
|
* Sets video status
|
|
233
267
|
* @param {boolean} value Video on or off
|
|
@@ -236,6 +270,10 @@ class VoiceConnection extends EventEmitter {
|
|
|
236
270
|
if (value === this.videoStatus) return;
|
|
237
271
|
if (this.status !== VoiceStatus.CONNECTED) return;
|
|
238
272
|
this.videoStatus = value;
|
|
273
|
+
const attrs = this.videoAttributes ?? {};
|
|
274
|
+
const fps = attrs.fps ?? 30;
|
|
275
|
+
const height = attrs.height ?? 720;
|
|
276
|
+
const width = attrs.width ?? Math.round((height * 16) / 9);
|
|
239
277
|
if (!value) {
|
|
240
278
|
this.sockets.ws
|
|
241
279
|
.sendPacket({
|
|
@@ -251,27 +289,28 @@ class VoiceConnection extends EventEmitter {
|
|
|
251
289
|
this.emit('debug', e);
|
|
252
290
|
});
|
|
253
291
|
} else {
|
|
292
|
+
const { audioSsrc, videoSsrc, rtxSsrc } = this.getStreamSsrcs();
|
|
254
293
|
this.sockets.ws
|
|
255
294
|
.sendPacket({
|
|
256
295
|
op: VoiceOpcodes.SOURCES,
|
|
257
296
|
d: {
|
|
258
|
-
audio_ssrc:
|
|
259
|
-
video_ssrc:
|
|
260
|
-
rtx_ssrc:
|
|
297
|
+
audio_ssrc: audioSsrc,
|
|
298
|
+
video_ssrc: videoSsrc,
|
|
299
|
+
rtx_ssrc: rtxSsrc,
|
|
261
300
|
streams: [
|
|
262
301
|
{
|
|
263
302
|
type: 'video',
|
|
264
303
|
rid: '100',
|
|
265
|
-
ssrc:
|
|
304
|
+
ssrc: videoSsrc,
|
|
266
305
|
active: true,
|
|
267
306
|
quality: 100,
|
|
268
|
-
rtx_ssrc:
|
|
269
|
-
max_bitrate:
|
|
270
|
-
max_framerate:
|
|
307
|
+
rtx_ssrc: rtxSsrc,
|
|
308
|
+
max_bitrate: 10_000_000,
|
|
309
|
+
max_framerate: fps,
|
|
271
310
|
max_resolution: {
|
|
272
|
-
type: '
|
|
273
|
-
width
|
|
274
|
-
height
|
|
311
|
+
type: 'fixed',
|
|
312
|
+
width,
|
|
313
|
+
height,
|
|
275
314
|
},
|
|
276
315
|
},
|
|
277
316
|
],
|
|
@@ -305,11 +344,14 @@ class VoiceConnection extends EventEmitter {
|
|
|
305
344
|
self_mute: this.voice ? this.voice.selfMute : false,
|
|
306
345
|
self_deaf: this.voice ? this.voice.selfDeaf : false,
|
|
307
346
|
self_video: this.voice ? this.voice.selfVideo : false,
|
|
308
|
-
flags: 2,
|
|
309
347
|
},
|
|
310
348
|
options,
|
|
311
349
|
);
|
|
312
350
|
|
|
351
|
+
if (options.self_video) {
|
|
352
|
+
options.flags = 2;
|
|
353
|
+
}
|
|
354
|
+
|
|
313
355
|
this.emit('debug', `Sending voice state update: ${JSON.stringify(options)}`);
|
|
314
356
|
|
|
315
357
|
return this.channel.client.ws.broadcast({
|
|
@@ -371,6 +413,21 @@ class VoiceConnection extends EventEmitter {
|
|
|
371
413
|
this.checkAuthenticated();
|
|
372
414
|
} else if (sessionId !== this.authentication.sessionId) {
|
|
373
415
|
this.authentication.sessionId = sessionId;
|
|
416
|
+
if (
|
|
417
|
+
this.constructor.name !== 'StreamConnection' &&
|
|
418
|
+
[VoiceStatus.CONNECTED, VoiceStatus.CONNECTING].includes(this.status)
|
|
419
|
+
) {
|
|
420
|
+
this.status = VoiceStatus.RECONNECTING;
|
|
421
|
+
if (this.sockets.ws) {
|
|
422
|
+
this.sockets.ws.shutdown();
|
|
423
|
+
this.sockets.ws = null;
|
|
424
|
+
}
|
|
425
|
+
if (this.sockets.udp) {
|
|
426
|
+
this.sockets.udp.shutdown();
|
|
427
|
+
this.sockets.udp = null;
|
|
428
|
+
}
|
|
429
|
+
this.checkAuthenticated();
|
|
430
|
+
}
|
|
374
431
|
/**
|
|
375
432
|
* Emitted when a new session ID is received.
|
|
376
433
|
* @event VoiceConnection#newSession
|
|
@@ -386,8 +443,9 @@ class VoiceConnection extends EventEmitter {
|
|
|
386
443
|
*/
|
|
387
444
|
checkAuthenticated() {
|
|
388
445
|
const { token, endpoint, sessionId } = this.authentication;
|
|
446
|
+
const needsServerId = this.constructor.name === 'StreamConnection';
|
|
389
447
|
this.emit('debug', `Authenticated with sessionId ${sessionId}`);
|
|
390
|
-
if (token && endpoint && sessionId) {
|
|
448
|
+
if (token && endpoint && sessionId && (!needsServerId || this.serverId)) {
|
|
391
449
|
this.status = VoiceStatus.CONNECTING;
|
|
392
450
|
/**
|
|
393
451
|
* Emitted when we successfully initiate a voice connection.
|
|
@@ -440,8 +498,9 @@ class VoiceConnection extends EventEmitter {
|
|
|
440
498
|
* @private
|
|
441
499
|
*/
|
|
442
500
|
authenticate(options = {}) {
|
|
501
|
+
this._joinOptions = options;
|
|
443
502
|
this.sendVoiceStateUpdate(options);
|
|
444
|
-
this.connectTimeout = setTimeout(() => this.authenticateFailed('VOICE_CONNECTION_TIMEOUT'),
|
|
503
|
+
this.connectTimeout = setTimeout(() => this.authenticateFailed('VOICE_CONNECTION_TIMEOUT'), 30_000).unref();
|
|
445
504
|
}
|
|
446
505
|
|
|
447
506
|
/**
|
|
@@ -500,6 +559,16 @@ class VoiceConnection extends EventEmitter {
|
|
|
500
559
|
cleanup() {
|
|
501
560
|
this.player.destroy();
|
|
502
561
|
this.speaking = new Speaking().freeze();
|
|
562
|
+
if (this.dave) {
|
|
563
|
+
const isSharedDave =
|
|
564
|
+
this.constructor.name === 'StreamConnection' && this.voiceConnection?.dave === this.dave;
|
|
565
|
+
if (!isSharedDave) {
|
|
566
|
+
this.dave.destroy();
|
|
567
|
+
this.dave.removeAllListeners();
|
|
568
|
+
}
|
|
569
|
+
this.dave = null;
|
|
570
|
+
}
|
|
571
|
+
this.connectedClients.clear();
|
|
503
572
|
const { ws, udp } = this.sockets;
|
|
504
573
|
|
|
505
574
|
this.emit('debug', 'Connection clean up');
|
|
@@ -571,11 +640,21 @@ class VoiceConnection extends EventEmitter {
|
|
|
571
640
|
* @param {Object} data The received data
|
|
572
641
|
* @private
|
|
573
642
|
*/
|
|
643
|
+
isDaveReady() {
|
|
644
|
+
return (
|
|
645
|
+
!this.authentication.dave_protocol_version ||
|
|
646
|
+
(this.dave?.lastTransitionId === 0 && Boolean(this.dave?.session?.ready))
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
|
|
574
650
|
onSessionDescription(data) {
|
|
575
651
|
Object.assign(this.authentication, data);
|
|
576
652
|
this.status = VoiceStatus.CONNECTED;
|
|
653
|
+
clearTimeout(this.connectTimeout);
|
|
654
|
+
let daveFallback;
|
|
577
655
|
const ready = () => {
|
|
578
656
|
clearTimeout(this.connectTimeout);
|
|
657
|
+
clearTimeout(daveFallback);
|
|
579
658
|
this.emit('debug', `Ready with authentication details: ${JSON.stringify(this.authentication)}`);
|
|
580
659
|
/**
|
|
581
660
|
* Emitted once the connection is ready, when a promise to join a voice channel resolves,
|
|
@@ -584,10 +663,32 @@ class VoiceConnection extends EventEmitter {
|
|
|
584
663
|
*/
|
|
585
664
|
this.emit('ready');
|
|
586
665
|
};
|
|
587
|
-
|
|
666
|
+
const waitForDave = () => {
|
|
667
|
+
if (this.isDaveReady()) {
|
|
668
|
+
ready();
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const onTransitioned = () => {
|
|
672
|
+
if (this.isDaveReady()) {
|
|
673
|
+
this.sockets.ws?.removeListener('transitioned', onTransitioned);
|
|
674
|
+
ready();
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
this.sockets.ws?.on('transitioned', onTransitioned);
|
|
678
|
+
daveFallback = setTimeout(() => {
|
|
679
|
+
this.sockets.ws?.removeListener('transitioned', onTransitioned);
|
|
680
|
+
this.emit('debug', 'DAVE handshake incomplete, continuing with transport encryption only');
|
|
681
|
+
ready();
|
|
682
|
+
}, 15_000).unref();
|
|
683
|
+
};
|
|
684
|
+
if (data.dave_protocol_version) {
|
|
685
|
+
if (!this.dispatcher && !this.videoDispatcher) {
|
|
686
|
+
this.playAudio(new SingleSilence(), { type: 'opus', volume: false });
|
|
687
|
+
}
|
|
688
|
+
waitForDave();
|
|
689
|
+
} else if (this.dispatcher || this.videoDispatcher) {
|
|
588
690
|
ready();
|
|
589
691
|
} else {
|
|
590
|
-
// This serves to provide support for voice receive, sending audio is required to receive it.
|
|
591
692
|
const dispatcher = this.playAudio(new SingleSilence(), { type: 'opus', volume: false });
|
|
592
693
|
dispatcher.once('finish', ready);
|
|
593
694
|
}
|
|
@@ -672,12 +773,97 @@ class VoiceConnection extends EventEmitter {
|
|
|
672
773
|
* Create new connection to screenshare stream
|
|
673
774
|
* @returns {Promise<StreamConnection>}
|
|
674
775
|
*/
|
|
776
|
+
waitForDaveReady() {
|
|
777
|
+
if (this.isDaveReady() || this.status === VoiceStatus.CONNECTED) return Promise.resolve();
|
|
778
|
+
return new Promise((resolve, reject) => {
|
|
779
|
+
const timeout = setTimeout(() => reject(new Error('VOICE_CONNECTION_TIMEOUT')), 30_000).unref();
|
|
780
|
+
const onReady = () => {
|
|
781
|
+
if (!this.isDaveReady()) return;
|
|
782
|
+
clearTimeout(timeout);
|
|
783
|
+
this.sockets.ws?.removeListener('transitioned', onReady);
|
|
784
|
+
resolve();
|
|
785
|
+
};
|
|
786
|
+
this.sockets.ws?.on('transitioned', onReady);
|
|
787
|
+
onReady();
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
stopExistingStream() {
|
|
792
|
+
return new Promise(resolve => {
|
|
793
|
+
const hadStreamConnection = Boolean(this.streamConnection);
|
|
794
|
+
if (this.streamConnection) {
|
|
795
|
+
this.streamConnection.disconnect();
|
|
796
|
+
this.streamConnection = null;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const streamKey = `${['DM', 'GROUP_DM'].includes(this.channel.type) ? 'call' : `guild:${this.channel.guild.id}`}:${
|
|
800
|
+
this.channel.id
|
|
801
|
+
}:${this.client.user.id}`;
|
|
802
|
+
|
|
803
|
+
const voiceState = this.channel.guild?.voiceStates.cache.get(this.client.user.id);
|
|
804
|
+
const wasStreaming = this.client.user?.voice?.streaming || voiceState?.streaming;
|
|
805
|
+
|
|
806
|
+
if (!wasStreaming && !hadStreamConnection) {
|
|
807
|
+
setTimeout(resolve, 200).unref();
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const sendDelete = () => {
|
|
812
|
+
this.channel.client.ws.broadcast({
|
|
813
|
+
op: Opcodes.STREAM_DELETE,
|
|
814
|
+
d: { stream_key: streamKey },
|
|
815
|
+
});
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
sendDelete();
|
|
819
|
+
if (wasStreaming) setTimeout(sendDelete, 400).unref();
|
|
820
|
+
|
|
821
|
+
let settled = false;
|
|
822
|
+
const cleanup = () => {
|
|
823
|
+
this.client.off(Events.VOICE_STATE_UPDATE, onState);
|
|
824
|
+
this.off('ready', onVoiceReady);
|
|
825
|
+
};
|
|
826
|
+
const finish = () => {
|
|
827
|
+
if (settled) return;
|
|
828
|
+
if (this.status !== VoiceStatus.CONNECTED) return;
|
|
829
|
+
settled = true;
|
|
830
|
+
cleanup();
|
|
831
|
+
setTimeout(resolve, 500).unref();
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const onState = (_oldState, newState) => {
|
|
835
|
+
if (newState.id !== this.client.user.id || newState.streaming) return;
|
|
836
|
+
finish();
|
|
837
|
+
};
|
|
838
|
+
const onVoiceReady = () => finish();
|
|
839
|
+
|
|
840
|
+
this.client.on(Events.VOICE_STATE_UPDATE, onState);
|
|
841
|
+
this.on('ready', onVoiceReady);
|
|
842
|
+
|
|
843
|
+
if (!wasStreaming) {
|
|
844
|
+
setTimeout(resolve, 1500).unref();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
setTimeout(() => finish(), 2000).unref();
|
|
849
|
+
setTimeout(() => {
|
|
850
|
+
if (settled) return;
|
|
851
|
+
settled = true;
|
|
852
|
+
cleanup();
|
|
853
|
+
resolve();
|
|
854
|
+
}, 12_000).unref();
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
675
858
|
createStreamConnection() {
|
|
676
859
|
// eslint-disable-next-line consistent-return
|
|
677
860
|
return new Promise((resolve, reject) => {
|
|
678
861
|
if (this.streamConnection) {
|
|
679
862
|
return resolve(this.streamConnection);
|
|
680
863
|
} else {
|
|
864
|
+
this.waitForDaveReady()
|
|
865
|
+
.then(() => this.stopExistingStream())
|
|
866
|
+
.then(() => {
|
|
681
867
|
const connection = (this.streamConnection = new StreamConnection(this.voiceManager, this.channel, this));
|
|
682
868
|
connection.setVideoCodec(this.videoCodec); // Sync :?
|
|
683
869
|
// Setup event...
|
|
@@ -697,7 +883,7 @@ class VoiceConnection extends EventEmitter {
|
|
|
697
883
|
// Current user stream
|
|
698
884
|
switch (event) {
|
|
699
885
|
case 'STREAM_CREATE': {
|
|
700
|
-
this.streamConnection.setSessionId(this.authentication.sessionId);
|
|
886
|
+
this.streamConnection.setSessionId(data.session_id || this.authentication.sessionId);
|
|
701
887
|
this.streamConnection.serverId = data.rtc_server_id;
|
|
702
888
|
break;
|
|
703
889
|
}
|
|
@@ -706,6 +892,13 @@ class VoiceConnection extends EventEmitter {
|
|
|
706
892
|
break;
|
|
707
893
|
}
|
|
708
894
|
case 'STREAM_DELETE': {
|
|
895
|
+
if (
|
|
896
|
+
[VoiceStatus.CONNECTING, VoiceStatus.AUTHENTICATING, VoiceStatus.RECONNECTING].includes(
|
|
897
|
+
this.streamConnection.status,
|
|
898
|
+
)
|
|
899
|
+
) {
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
709
902
|
this.streamConnection.disconnect();
|
|
710
903
|
break;
|
|
711
904
|
}
|
|
@@ -743,7 +936,7 @@ class VoiceConnection extends EventEmitter {
|
|
|
743
936
|
}
|
|
744
937
|
|
|
745
938
|
connection.sendSignalScreenshare();
|
|
746
|
-
connection.
|
|
939
|
+
connection.connectTimeout = setTimeout(() => connection.authenticateFailed('VOICE_CONNECTION_TIMEOUT'), 30_000).unref();
|
|
747
940
|
|
|
748
941
|
connection.on('debug', msg =>
|
|
749
942
|
this.channel.client.emit(
|
|
@@ -760,6 +953,7 @@ class VoiceConnection extends EventEmitter {
|
|
|
760
953
|
|
|
761
954
|
connection.once('authenticated', () => {
|
|
762
955
|
connection.once('ready', () => {
|
|
956
|
+
connection.sendScreenshareState(false);
|
|
763
957
|
resolve(connection);
|
|
764
958
|
connection.removeListener('error', reject);
|
|
765
959
|
});
|
|
@@ -767,6 +961,8 @@ class VoiceConnection extends EventEmitter {
|
|
|
767
961
|
this.streamConnection = null;
|
|
768
962
|
});
|
|
769
963
|
});
|
|
964
|
+
})
|
|
965
|
+
.catch(reject);
|
|
770
966
|
}
|
|
771
967
|
});
|
|
772
968
|
}
|
|
@@ -811,7 +1007,7 @@ class VoiceConnection extends EventEmitter {
|
|
|
811
1007
|
// Current user stream
|
|
812
1008
|
switch (event) {
|
|
813
1009
|
case 'STREAM_CREATE': {
|
|
814
|
-
this.streamConnection.setSessionId(this.authentication.sessionId);
|
|
1010
|
+
this.streamConnection.setSessionId(data.session_id || this.authentication.sessionId);
|
|
815
1011
|
this.streamConnection.serverId = data.rtc_server_id;
|
|
816
1012
|
break;
|
|
817
1013
|
}
|
|
@@ -820,6 +1016,13 @@ class VoiceConnection extends EventEmitter {
|
|
|
820
1016
|
break;
|
|
821
1017
|
}
|
|
822
1018
|
case 'STREAM_DELETE': {
|
|
1019
|
+
if (
|
|
1020
|
+
[VoiceStatus.CONNECTING, VoiceStatus.AUTHENTICATING, VoiceStatus.RECONNECTING].includes(
|
|
1021
|
+
this.streamConnection.status,
|
|
1022
|
+
)
|
|
1023
|
+
) {
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
823
1026
|
this.streamConnection.disconnect();
|
|
824
1027
|
break;
|
|
825
1028
|
}
|
|
@@ -963,6 +1166,49 @@ class StreamConnection extends VoiceConnection {
|
|
|
963
1166
|
return Promise.resolve(this);
|
|
964
1167
|
}
|
|
965
1168
|
|
|
1169
|
+
setTokenAndEndpoint(token, endpoint) {
|
|
1170
|
+
this.emit('debug', `Token "${token}" and endpoint "${endpoint}"`);
|
|
1171
|
+
if (!endpoint) return;
|
|
1172
|
+
|
|
1173
|
+
if (!token) {
|
|
1174
|
+
this.authenticateFailed('VOICE_TOKEN_ABSENT');
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
endpoint = normalizeVoiceEndpoint(endpoint, { preservePort: true });
|
|
1179
|
+
this.emit('debug', `Endpoint resolved as ${endpoint}`);
|
|
1180
|
+
|
|
1181
|
+
if (!endpoint) {
|
|
1182
|
+
this.authenticateFailed('VOICE_INVALID_ENDPOINT');
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const connect = () => {
|
|
1187
|
+
if (this.status === VoiceStatus.AUTHENTICATING) {
|
|
1188
|
+
this.authentication.token = token;
|
|
1189
|
+
this.authentication.endpoint = endpoint;
|
|
1190
|
+
this.checkAuthenticated();
|
|
1191
|
+
} else if (token !== this.authentication.token || endpoint !== this.authentication.endpoint) {
|
|
1192
|
+
this.reconnect(token, endpoint);
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
if (this.status === VoiceStatus.AUTHENTICATING) {
|
|
1197
|
+
setTimeout(connect, 750).unref();
|
|
1198
|
+
} else {
|
|
1199
|
+
connect();
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
onSessionDescription(data) {
|
|
1204
|
+
Object.assign(this.authentication, data);
|
|
1205
|
+
this.status = VoiceStatus.CONNECTED;
|
|
1206
|
+
this._sessionRetries = 0;
|
|
1207
|
+
clearTimeout(this.connectTimeout);
|
|
1208
|
+
this.emit('debug', `Ready with authentication details: ${JSON.stringify(this.authentication)}`);
|
|
1209
|
+
this.emit('ready');
|
|
1210
|
+
}
|
|
1211
|
+
|
|
966
1212
|
joinStreamConnection() {
|
|
967
1213
|
throw new Error('STREAM_CANNOT_JOIN');
|
|
968
1214
|
}
|
|
@@ -1005,10 +1251,11 @@ class StreamConnection extends VoiceConnection {
|
|
|
1005
1251
|
preferred_region: null,
|
|
1006
1252
|
};
|
|
1007
1253
|
this.emit('debug', `Signal Stream: ${JSON.stringify(data)}`);
|
|
1008
|
-
|
|
1254
|
+
this.channel.client.ws.broadcast({
|
|
1009
1255
|
op: Opcodes.STREAM_CREATE,
|
|
1010
1256
|
d: data,
|
|
1011
1257
|
});
|
|
1258
|
+
this.sendScreenshareState(false);
|
|
1012
1259
|
}
|
|
1013
1260
|
|
|
1014
1261
|
/**
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('events');
|
|
4
|
+
const { PassThrough } = require('stream');
|
|
5
|
+
const ffmpeg = require('fluent-ffmpeg');
|
|
6
|
+
const { Streamer, playStream } = require('@dank074/discord-video-stream');
|
|
7
|
+
const StreamSession = require('./StreamSession');
|
|
8
|
+
const StageChannel = require('../../structures/StageChannel');
|
|
9
|
+
|
|
10
|
+
if (typeof globalThis.WebSocket === 'undefined') {
|
|
11
|
+
globalThis.WebSocket = require('ws');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let _ffmpegPathReady = null;
|
|
15
|
+
|
|
16
|
+
/** Utilise le ffmpeg jellyfin de node-av (libx264/libopus) si disponible. */
|
|
17
|
+
function ensureFfmpegPath() {
|
|
18
|
+
if (_ffmpegPathReady) return _ffmpegPathReady;
|
|
19
|
+
_ffmpegPathReady = import('node-av/ffmpeg')
|
|
20
|
+
.then(({ ffmpegPath, isFfmpegAvailable }) => {
|
|
21
|
+
if (!isFfmpegAvailable()) {
|
|
22
|
+
console.warn('[stream] ffmpeg node-av introuvable — ffmpeg système utilisé (vérifie libx264/libopus)');
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const path = ffmpegPath();
|
|
26
|
+
ffmpeg.setFfmpegPath(path);
|
|
27
|
+
console.log(`[stream] ffmpeg: ${path}`);
|
|
28
|
+
return path;
|
|
29
|
+
})
|
|
30
|
+
.catch(() => {
|
|
31
|
+
console.warn('[stream] impossible de charger node-av/ffmpeg — ffmpeg système utilisé');
|
|
32
|
+
return null;
|
|
33
|
+
});
|
|
34
|
+
return _ffmpegPathReady;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Go Live via WebRTC — ffmpeg custom (sans azmq) + playStream de la librairie.
|
|
39
|
+
* @extends {EventEmitter}
|
|
40
|
+
*/
|
|
41
|
+
class WebRtcStreamSession extends EventEmitter {
|
|
42
|
+
static resolveUrl = StreamSession.resolveUrl;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {import('../Client')} client
|
|
46
|
+
* @param {import('./StreamSession').StartStreamOptions} options
|
|
47
|
+
*/
|
|
48
|
+
constructor(client, options) {
|
|
49
|
+
super();
|
|
50
|
+
this.client = client;
|
|
51
|
+
this.options = options;
|
|
52
|
+
this.streamer = new Streamer(client);
|
|
53
|
+
this._abort = new AbortController();
|
|
54
|
+
this._playPromise = null;
|
|
55
|
+
this._command = null;
|
|
56
|
+
this._started = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Construit la commande ffmpeg : transcode en H264 + Opus, sortie nut.
|
|
61
|
+
* Pas de filtre azmq (compatibilité ffmpeg sans libzmq).
|
|
62
|
+
*/
|
|
63
|
+
_buildFfmpeg(url) {
|
|
64
|
+
const {
|
|
65
|
+
fps,
|
|
66
|
+
height = 720,
|
|
67
|
+
width = -2,
|
|
68
|
+
bitrate = 5000,
|
|
69
|
+
bitrateMax,
|
|
70
|
+
audioBitrate = 128,
|
|
71
|
+
preset = 'superfast',
|
|
72
|
+
tune = 'film',
|
|
73
|
+
audio,
|
|
74
|
+
} = this.options;
|
|
75
|
+
|
|
76
|
+
const maxRate = bitrateMax ?? Math.round(bitrate * 1.5);
|
|
77
|
+
const includeAudio = audio !== false;
|
|
78
|
+
const output = new PassThrough();
|
|
79
|
+
const command = ffmpeg(url);
|
|
80
|
+
|
|
81
|
+
const isHttp = typeof url === 'string' && /^https?:\/\//i.test(url);
|
|
82
|
+
if (isHttp) {
|
|
83
|
+
command.inputOptions([
|
|
84
|
+
'-reconnect 1',
|
|
85
|
+
'-reconnect_at_eof 1',
|
|
86
|
+
'-reconnect_streamed 1',
|
|
87
|
+
'-reconnect_delay_max 4294',
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
command.output(output).outputFormat('nut');
|
|
92
|
+
|
|
93
|
+
command.addOutputOption('-map 0:v');
|
|
94
|
+
command.videoFilter(`scale=${width}:${height}`);
|
|
95
|
+
// fps forcé seulement si demandé explicitement (sinon on garde celui de la source = vitesse 1x)
|
|
96
|
+
if (fps && fps > 0) command.fpsOutput(fps);
|
|
97
|
+
command.addOutputOption([
|
|
98
|
+
'-b:v',
|
|
99
|
+
`${bitrate}k`,
|
|
100
|
+
'-maxrate:v',
|
|
101
|
+
`${maxRate}k`,
|
|
102
|
+
'-bufsize:v',
|
|
103
|
+
`${Math.round(bitrate / 2)}k`,
|
|
104
|
+
'-bf',
|
|
105
|
+
'0',
|
|
106
|
+
'-pix_fmt',
|
|
107
|
+
'yuv420p',
|
|
108
|
+
'-force_key_frames',
|
|
109
|
+
'expr:gte(t,n_forced*1)',
|
|
110
|
+
]);
|
|
111
|
+
command.videoCodec('libx264');
|
|
112
|
+
command.outputOptions(['-forced-idr 1', `-tune ${tune}`, `-preset ${preset}`]);
|
|
113
|
+
|
|
114
|
+
if (includeAudio) {
|
|
115
|
+
command.addOutputOption('-map 0:a:0?');
|
|
116
|
+
command.audioChannels(2);
|
|
117
|
+
command.audioFrequency(48000);
|
|
118
|
+
command.audioCodec('libopus');
|
|
119
|
+
command.audioBitrate(`${audioBitrate}k`);
|
|
120
|
+
command.audioFilters('volume=1');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { command, output };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async _run(url, signal) {
|
|
127
|
+
await ensureFfmpegPath();
|
|
128
|
+
|
|
129
|
+
const { command, output } = this._buildFfmpeg(url);
|
|
130
|
+
this._command = command;
|
|
131
|
+
|
|
132
|
+
command.on('start', cmd => this.emit('debug', `[cmd] ${cmd}`));
|
|
133
|
+
command.on('stderr', line => this.emit('debug', String(line)));
|
|
134
|
+
|
|
135
|
+
command.on('error', (err, _stdout, stderr) => {
|
|
136
|
+
if (stderr) this.emit('debug', String(stderr));
|
|
137
|
+
if (signal.aborted) return;
|
|
138
|
+
const msg = err?.message || String(err);
|
|
139
|
+
this.emit('error', new Error(stderr ? `${msg}\n${stderr}` : msg));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
signal.addEventListener('abort', () => command.kill('SIGTERM'), { once: true });
|
|
143
|
+
command.run();
|
|
144
|
+
|
|
145
|
+
const { livestream } = this.options;
|
|
146
|
+
const playOptions = { type: 'go-live' };
|
|
147
|
+
if (livestream) playOptions.readrateInitialBurst = 10;
|
|
148
|
+
|
|
149
|
+
this._playPromise = playStream(output, this.streamer, playOptions, signal);
|
|
150
|
+
|
|
151
|
+
this._playPromise
|
|
152
|
+
.then(() => this.emit('finish'))
|
|
153
|
+
.catch(err => {
|
|
154
|
+
if (signal.aborted || err?.name === 'AbortError') return;
|
|
155
|
+
this.emit('error', err);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return this._playPromise;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async start() {
|
|
162
|
+
const { guildId, channelId, url } = this.options;
|
|
163
|
+
await ensureFfmpegPath();
|
|
164
|
+
await this.streamer.joinVoice(guildId, channelId);
|
|
165
|
+
|
|
166
|
+
if (this.client.user?.voice?.channel instanceof StageChannel) {
|
|
167
|
+
await this.client.user.voice.setSuppressed(false);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this._started = true;
|
|
171
|
+
void this._run(url, this._abort.signal);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
stop() {
|
|
175
|
+
this._abort.abort();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async replay() {
|
|
179
|
+
this.stop();
|
|
180
|
+
this._abort = new AbortController();
|
|
181
|
+
await this._run(this.options.url, this._abort.signal);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
disconnect() {
|
|
185
|
+
this.stop();
|
|
186
|
+
if (this._started) this.streamer.leaveVoice();
|
|
187
|
+
this._started = false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = WebRtcStreamSession;
|