djs-selfbot-v13 3.7.36 → 3.7.38

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "djs-selfbot-v13",
3
- "version": "3.7.36",
3
+ "version": "3.7.38",
4
4
  "description": "An unofficial discord.js fork for creating selfbots",
5
5
  "main": "./src/index.js",
6
6
  "types": "./typings/index.d.ts",
@@ -932,6 +932,11 @@ class Client extends BaseClient {
932
932
  audio,
933
933
  livestream = false,
934
934
  downloadHttp = true,
935
+ encoder,
936
+ hardwareAcceleratedDecoding,
937
+ nvencPreset,
938
+ preEncode,
939
+ goLive,
935
940
  } = options;
936
941
 
937
942
  if (!url) throw new Error('STREAM_URL_REQUIRED');
@@ -939,7 +944,7 @@ class Client extends BaseClient {
939
944
 
940
945
  let playUrl = url;
941
946
  if (typeof url === 'string' && url.startsWith('http') && downloadHttp) {
942
- playUrl = await WebRtcStreamSession.resolveUrl(url);
947
+ playUrl = await StreamSession.resolveUrl(url);
943
948
  }
944
949
 
945
950
  const session = new WebRtcStreamSession(this, {
@@ -956,6 +961,11 @@ class Client extends BaseClient {
956
961
  tune,
957
962
  audio,
958
963
  livestream,
964
+ encoder,
965
+ hardwareAcceleratedDecoding,
966
+ nvencPreset,
967
+ preEncode,
968
+ goLive,
959
969
  });
960
970
 
961
971
  await session.start();
@@ -164,6 +164,80 @@ class ClientVoiceManager {
164
164
  });
165
165
  }
166
166
 
167
+ /**
168
+ * Force quitter vocal + stream (session stale / erreur 4006).
169
+ * @param {import('../../structures/VoiceChannel') | import('../../structures/StageChannel')} channel
170
+ * @returns {Promise<void>}
171
+ */
172
+ resetVoiceSession(channel) {
173
+ return new Promise(resolve => {
174
+ if (this.connection) {
175
+ if (this.connection.streamConnection) {
176
+ this.connection.streamConnection.disconnect();
177
+ this.connection.streamConnection = null;
178
+ }
179
+ this.connection.disconnect();
180
+ this.connection = null;
181
+ }
182
+
183
+ const guild = channel.guild;
184
+ if (!guild) {
185
+ setTimeout(resolve, 1000).unref();
186
+ return;
187
+ }
188
+
189
+ const userId = this.client.user?.id;
190
+ const voiceState = guild.voiceStates.cache.get(userId);
191
+ const channelId = voiceState?.channelId ?? channel.id;
192
+ const streamKey = `guild:${guild.id}:${channelId}:${userId}`;
193
+
194
+ this.client.ws.broadcast({
195
+ op: Opcodes.STREAM_DELETE,
196
+ d: { stream_key: streamKey },
197
+ });
198
+ this.client.ws.broadcast({
199
+ op: Opcodes.VOICE_STATE_UPDATE,
200
+ d: {
201
+ guild_id: guild.id,
202
+ channel_id: null,
203
+ self_mute: false,
204
+ self_deaf: false,
205
+ },
206
+ });
207
+
208
+ this.client.emit('debug', '[VOICE] resetVoiceSession: vocal et stream réinitialisés');
209
+ setTimeout(resolve, 2500).unref();
210
+ });
211
+ }
212
+
213
+ /**
214
+ * @param {import('../../structures/VoiceChannel') | import('../../structures/StageChannel')} channel
215
+ * @param {object} [config]
216
+ * @param {number} [maxAttempts=3]
217
+ * @returns {Promise<VoiceConnection>}
218
+ */
219
+ async joinChannelWithRetry(channel, config = {}, maxAttempts = 3) {
220
+ let lastError;
221
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
222
+ try {
223
+ if (attempt > 1) {
224
+ console.log(`[stream] reconnexion vocale (${attempt}/${maxAttempts})…`);
225
+ await this.resetVoiceSession(channel);
226
+ }
227
+ return await this.joinChannel(channel, config);
228
+ } catch (err) {
229
+ lastError = err;
230
+ const expired =
231
+ err?.code === 'VOICE_SESSION_EXPIRED' ||
232
+ String(err?.message ?? '').includes('4006') ||
233
+ String(err?.message ?? '').includes('VOICE_SESSION_EXPIRED');
234
+ if (!expired || attempt === maxAttempts) throw err;
235
+ await this.resetVoiceSession(channel);
236
+ }
237
+ }
238
+ throw lastError;
239
+ }
240
+
167
241
  /**
168
242
  * Sets up a request to join a voice channel.
169
243
  * @param {VoiceChannel | StageChannel | DMChannel | GroupDMChannel | Snowflake} channel The voice channel to join
@@ -4,6 +4,7 @@ const fs = require('node:fs');
4
4
  const os = require('node:os');
5
5
  const path = require('node:path');
6
6
  const { EventEmitter } = require('events');
7
+ const StageChannel = require('../../structures/StageChannel');
7
8
 
8
9
  /**
9
10
  * Active screenshare session returned by {@link Client#startStream}.
@@ -53,16 +54,27 @@ class StreamSession extends EventEmitter {
53
54
  return Math.max(0, this.videoDispatcher.totalStreamTime - this.videoDispatcher.pausedTime);
54
55
  }
55
56
 
57
+ /**
58
+ * Starts playback on the stream connection.
59
+ */
60
+ start() {
61
+ if (this.client.user?.voice?.channel instanceof StageChannel) {
62
+ void this.client.user.voice.setSuppressed(false);
63
+ }
64
+ return this._play();
65
+ }
66
+
56
67
  _play(seek = 0) {
57
68
  const { url, fps, height, width, bitrate, audioBitrate } = this.options;
69
+ const streamFps = fps ?? 30;
58
70
  const streamHeight = height ?? 720;
59
71
  this.streamConnection.videoAttributes = {
60
72
  width: width ?? Math.round((streamHeight * 16) / 9),
61
73
  height: streamHeight,
62
- fps: fps ?? 30,
74
+ fps: streamFps,
63
75
  };
64
76
  const videoOptions = {
65
- fps,
77
+ fps: streamFps,
66
78
  bitrate,
67
79
  seek,
68
80
  presetH26x: this.options.preset || 'ultrafast',
@@ -70,9 +82,9 @@ class StreamSession extends EventEmitter {
70
82
  '-pix_fmt',
71
83
  'yuv420p',
72
84
  '-g',
73
- String(fps),
85
+ String(streamFps),
74
86
  '-keyint_min',
75
- String(fps),
87
+ String(streamFps),
76
88
  '-sc_threshold',
77
89
  '0',
78
90
  '-force_key_frames',
@@ -90,6 +102,8 @@ class StreamSession extends EventEmitter {
90
102
  videoOptions.inputFFmpegArgs = ['-reconnect', '1', '-reconnect_at_eof', '1', '-reconnect_streamed', '1'];
91
103
  }
92
104
 
105
+ console.log(`[stream] lecture ${width ?? '?'}x${streamHeight} @ ${streamFps}fps (UDP)`);
106
+
93
107
  this.videoDispatcher = this.streamConnection.playVideo(url, videoOptions);
94
108
 
95
109
  if (this.options.audio !== false) {
@@ -190,4 +204,9 @@ module.exports = StreamSession;
190
204
  * @property {boolean} [downloadHttp=true] Download HTTP URLs locally before playback
191
205
  * @property {number} [bitrateMax] Max video bitrate in kbps
192
206
  * @property {boolean} [livestream=false] Enable readrateInitialBurst (live sources)
207
+ * @property {'auto' | 'amf' | 'nvenc' | 'qsv' | 'software'} [encoder='auto'] Video encoder
208
+ * @property {boolean} [hardwareAcceleratedDecoding=true] Use GPU decoding (-hwaccel auto)
209
+ * @property {string} [nvencPreset='p1'] NVENC preset (p1 = lowest latency)
210
+ * @property {boolean} [preEncode=true] Pré-encode en local avant lecture (fluidité)
211
+ * @property {boolean} [goLive=false] WebRTC Go Live (sinon UDP classique, plus stable)
193
212
  */
@@ -1,9 +1,17 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
3
6
  const { EventEmitter } = require('events');
4
- const { PassThrough } = require('stream');
7
+ const { spawn } = require('child_process');
5
8
  const ffmpeg = require('fluent-ffmpeg');
6
- const { Streamer, playStream } = require('@dank074/discord-video-stream');
9
+ const {
10
+ Streamer,
11
+ prepareStream,
12
+ playStream,
13
+ Encoders,
14
+ } = require('@dank074/discord-video-stream');
7
15
  const StreamSession = require('./StreamSession');
8
16
  const StageChannel = require('../../structures/StageChannel');
9
17
 
@@ -11,31 +19,55 @@ if (typeof globalThis.WebSocket === 'undefined') {
11
19
  globalThis.WebSocket = require('ws');
12
20
  }
13
21
 
14
- let _ffmpegPathReady = null;
22
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
15
23
 
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
+ /** @type {'amf' | 'nvenc' | 'software' | null} */
25
+ let _cachedEncoder = null;
26
+
27
+ function detectHwEncoder() {
28
+ if (_cachedEncoder) return Promise.resolve(_cachedEncoder);
29
+ return new Promise(resolve => {
30
+ ffmpeg.getAvailableEncoders((err, encoders) => {
31
+ if (err || !encoders) {
32
+ _cachedEncoder = 'software';
33
+ resolve('software');
34
+ return;
35
+ }
36
+ if (encoders.h264_amf) {
37
+ _cachedEncoder = 'amf';
38
+ resolve('amf');
39
+ return;
24
40
  }
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;
41
+ if (encoders.h264_nvenc) {
42
+ const proc = spawn('ffmpeg', [
43
+ '-hide_banner', '-loglevel', 'error',
44
+ '-f', 'lavfi', '-i', 'color=c=black:s=64x64:d=0.04',
45
+ '-c:v', 'h264_nvenc', '-f', 'null', '-',
46
+ ]);
47
+ proc.on('close', code => {
48
+ if (code === 0) {
49
+ _cachedEncoder = 'nvenc';
50
+ resolve('nvenc');
51
+ } else {
52
+ _cachedEncoder = 'software';
53
+ resolve('software');
54
+ }
55
+ });
56
+ proc.on('error', () => {
57
+ _cachedEncoder = 'software';
58
+ resolve('software');
59
+ });
60
+ return;
61
+ }
62
+ _cachedEncoder = 'software';
63
+ resolve('software');
33
64
  });
34
- return _ffmpegPathReady;
65
+ });
35
66
  }
36
67
 
37
68
  /**
38
- * Go Live via WebRTC — ffmpeg custom (sans azmq) + playStream de la librairie.
69
+ * Go Live WebRTC — même approche que discord-livestream-selfbot :
70
+ * Streamer.joinVoice + prepareStream + playStream
39
71
  * @extends {EventEmitter}
40
72
  */
41
73
  class WebRtcStreamSession extends EventEmitter {
@@ -51,152 +83,192 @@ class WebRtcStreamSession extends EventEmitter {
51
83
  this.options = options;
52
84
  this.streamer = new Streamer(client);
53
85
  this._abort = new AbortController();
86
+ this._ffmpegCommand = null;
54
87
  this._playPromise = null;
55
- this._command = null;
56
- this._ffmpegProc = null;
57
88
  this._started = false;
58
89
  this._running = false;
59
90
  this._paused = false;
60
91
  this._stopped = false;
61
92
  this._positionMs = 0;
62
93
  this._runStartWall = 0;
63
- this._pausedAt = 0;
64
- this._pausedTotalMs = 0;
65
94
  }
66
95
 
67
96
  _getPositionMs() {
68
97
  if (!this._running) return this._positionMs;
69
- if (this._paused) return this._positionMs;
70
- return this._positionMs + (Date.now() - this._runStartWall - this._pausedTotalMs);
98
+ return this._positionMs + (Date.now() - this._runStartWall);
71
99
  }
72
100
 
73
- _resetRunClock() {
74
- this._runStartWall = Date.now();
75
- this._pausedAt = 0;
76
- this._pausedTotalMs = 0;
77
- }
78
-
79
- _signalFfmpeg(signal) {
80
- const pid = this._ffmpegProc?.pid;
81
- if (!pid) return;
82
- try {
83
- process.kill(pid, signal);
84
- } catch {
85
- // process already exited
101
+ async _pickEncoder() {
102
+ const { preset = 'ultrafast', encoder = 'auto' } = this.options;
103
+ let mode = encoder;
104
+ if (encoder === 'auto') mode = await detectHwEncoder();
105
+
106
+ if (mode === 'nvenc') {
107
+ console.log('[stream] encodeur: h264_nvenc (p1 ll)');
108
+ const llOpts = [
109
+ '-preset', 'p1',
110
+ '-tune', 'll',
111
+ '-delay', '0',
112
+ '-rc-lookahead', '0',
113
+ '-no-scenecut', '1',
114
+ '-zerolatency', '1',
115
+ ];
116
+ return () => ({
117
+ H264: { name: 'h264_nvenc', options: llOpts },
118
+ H265: { name: 'hevc_nvenc', options: llOpts },
119
+ AV1: { name: 'av1_nvenc', options: llOpts },
120
+ });
86
121
  }
122
+ if (mode === 'amf') {
123
+ console.log('[stream] encodeur: h264_amf (ultra low latency)');
124
+ return () => ({
125
+ H264: {
126
+ name: 'h264_amf',
127
+ options: ['-usage', 'ultralowlatency', '-quality', 'speed', '-preanalysis', 'false'],
128
+ },
129
+ H265: {
130
+ name: 'hevc_amf',
131
+ options: ['-usage', 'ultralowlatency', '-quality', 'speed', '-preanalysis', 'false'],
132
+ },
133
+ });
134
+ }
135
+ console.log('[stream] encodeur: libx264');
136
+ return Encoders.software({
137
+ x264: { preset, tune: 'zerolatency' },
138
+ });
87
139
  }
88
140
 
89
- /**
90
- * Construit la commande ffmpeg : transcode en H264 + Opus, sortie nut.
91
- * Pas de filtre azmq (compatibilité ffmpeg sans libzmq).
92
- */
93
- _buildFfmpeg(url, seekSec = 0) {
141
+ async _buildPrepareOptions(seekSec = 0) {
94
142
  const {
95
- fps,
96
- height = 720,
97
- width = -2,
98
- bitrate = 5000,
143
+ fps = 60,
144
+ height,
145
+ width,
146
+ bitrate = 4500,
99
147
  bitrateMax,
100
148
  audioBitrate = 128,
101
- preset = 'superfast',
102
- tune = 'film',
149
+ hardwareAcceleratedDecoding = true,
103
150
  audio,
151
+ noTranscoding = false,
104
152
  } = this.options;
105
153
 
106
- const maxRate = bitrateMax ?? Math.round(bitrate * 1.5);
107
- const includeAudio = audio !== false;
108
- const output = new PassThrough();
109
- const command = ffmpeg(url);
110
-
111
- if (seekSec > 0) command.seekInput(seekSec);
112
-
113
- const isHttp = typeof url === 'string' && /^https?:\/\//i.test(url);
114
- if (isHttp) {
115
- command.inputOptions([
116
- '-reconnect 1',
117
- '-reconnect_at_eof 1',
118
- '-reconnect_streamed 1',
119
- '-reconnect_delay_max 4294',
120
- ]);
154
+ if (noTranscoding) {
155
+ console.log('[stream] mode copie vidéo (pas de ré-encodage)');
156
+ return {
157
+ noTranscoding: true,
158
+ bitrateAudio: audioBitrate,
159
+ includeAudio: audio !== false,
160
+ hardwareAcceleratedDecoding: false,
161
+ minimizeLatency: true,
162
+ customInputOptions: seekSec > 0 ? [`-ss ${String(seekSec)}`] : [],
163
+ customFfmpegFlags: [],
164
+ };
165
+ }
166
+
167
+ const gop = String(fps * 2);
168
+ const is720 = Number(height) >= 720;
169
+ const videoBufK = Math.max(Math.round(bitrate * 1.25), is720 ? 3500 : 1200);
170
+
171
+ return {
172
+ encoder: await this._pickEncoder(),
173
+ height,
174
+ width: width || (is720 ? 1280 : Math.round((height * 16) / 9 / 2) * 2),
175
+ frameRate: fps,
176
+ bitrateVideo: bitrate,
177
+ bitrateVideoMax: bitrateMax ?? Math.round(bitrate * 1.3),
178
+ bitrateAudio: audioBitrate,
179
+ includeAudio: audio !== false,
180
+ hardwareAcceleratedDecoding,
181
+ minimizeLatency: !is720,
182
+ customInputOptions: seekSec > 0 ? [`-ss ${String(seekSec)}`] : [],
183
+ customFfmpegFlags: [
184
+ '-g', gop,
185
+ '-keyint_min', gop,
186
+ '-sc_threshold', '0',
187
+ '-threads', '0',
188
+ '-bufsize:v', `${videoBufK}k`,
189
+ '-maxrate:v', `${bitrateMax ?? Math.round(bitrate * 1.3)}k`,
190
+ '-force_key_frames', 'expr:gte(t,n_forced*2)',
191
+ '-max_muxing_queue_size', '1024',
192
+ '-flush_packets', '1',
193
+ ],
194
+ };
195
+ }
196
+
197
+ async _disconnectLegacyVoice() {
198
+ const conn = this.client.voice?.connection;
199
+ if (conn) {
200
+ conn.disconnect();
201
+ this.client.voice.connection = null;
202
+ await sleep(800);
203
+ }
204
+ }
205
+
206
+ async _joinVoice() {
207
+ const { guildId, channelId } = this.options;
208
+
209
+ if (this.streamer.voiceConnection) {
210
+ this.streamer.leaveVoice();
211
+ await sleep(1000);
121
212
  }
122
213
 
123
- command.output(output).outputFormat('nut');
124
-
125
- command.addOutputOption('-map 0:v');
126
- command.videoFilter(`scale=${width}:${height}`);
127
- // fps forcé seulement si demandé explicitement (sinon on garde celui de la source = vitesse 1x)
128
- if (fps && fps > 0) command.fpsOutput(fps);
129
- command.addOutputOption([
130
- '-b:v',
131
- `${bitrate}k`,
132
- '-maxrate:v',
133
- `${maxRate}k`,
134
- '-bufsize:v',
135
- `${Math.round(bitrate / 2)}k`,
136
- '-bf',
137
- '0',
138
- '-pix_fmt',
139
- 'yuv420p',
140
- '-force_key_frames',
141
- 'expr:gte(t,n_forced*1)',
142
- ]);
143
- command.videoCodec('libx264');
144
- command.outputOptions(['-forced-idr 1', `-tune ${tune}`, `-preset ${preset}`]);
145
-
146
- if (includeAudio) {
147
- command.addOutputOption('-map 0:a:0?');
148
- command.audioChannels(2);
149
- command.audioFrequency(48000);
150
- command.audioCodec('libopus');
151
- command.audioBitrate(`${audioBitrate}k`);
152
- command.audioFilters('volume=1');
214
+ await this._disconnectLegacyVoice();
215
+
216
+ const vc = this.streamer.voiceConnection;
217
+ if (
218
+ !vc ||
219
+ vc.guildId !== guildId ||
220
+ vc.channelId !== channelId
221
+ ) {
222
+ await this.streamer.joinVoice(guildId, channelId);
153
223
  }
154
224
 
155
- return { command, output };
225
+ if (this.client.user?.voice?.channel instanceof StageChannel) {
226
+ await this.client.user.voice.setSuppressed(false);
227
+ }
156
228
  }
157
229
 
158
230
  async _run(url, signal, seekSec = 0) {
159
- await ensureFfmpegPath();
231
+ const prepareOpts = await this._buildPrepareOptions(seekSec);
232
+ const { command, output } = prepareStream(url, prepareOpts, signal);
233
+ this._ffmpegCommand = command;
160
234
 
161
- const { command, output } = this._buildFfmpeg(url, seekSec);
162
- this._command = command;
163
- this._ffmpegProc = null;
164
- this._running = true;
165
- this._paused = false;
166
- this._stopped = false;
167
- this._resetRunClock();
168
-
169
- command.on('start', cmd => {
170
- this._ffmpegProc = command.ffmpegProc ?? null;
171
- this.emit('debug', `[cmd] ${cmd}`);
172
- });
235
+ command.on('start', cmd => this.emit('debug', `[ffmpeg] ${cmd}`));
173
236
  command.on('stderr', line => this.emit('debug', String(line)));
174
-
175
237
  command.on('error', (err, _stdout, stderr) => {
176
- if (stderr) this.emit('debug', String(stderr));
177
238
  if (signal.aborted) return;
178
- const msg = err?.message || String(err);
179
- this.emit('error', new Error(stderr ? `${msg}\n${stderr}` : msg));
239
+ this.emit('error', new Error(stderr ? `${err.message}\n${stderr}` : err.message));
180
240
  });
181
241
 
182
- const onAbort = () => {
183
- this._running = false;
184
- this._ffmpegProc = null;
185
- command.kill('SIGTERM');
186
- };
187
- signal.addEventListener('abort', onAbort, { once: true });
188
- command.run();
242
+ this._running = true;
243
+ this._paused = false;
244
+ this._stopped = false;
245
+ this._runStartWall = Date.now();
189
246
 
190
- const { livestream } = this.options;
191
- const playOptions = { type: 'go-live' };
192
- if (livestream) playOptions.readrateInitialBurst = 10;
247
+ let playOptions;
248
+ if (this.options.noTranscoding) {
249
+ console.log('[stream] envoi à la résolution/fps natifs de la source (WebRTC)');
250
+ playOptions = {};
251
+ } else {
252
+ const { height = 720, width: optWidth, fps = 60, bitrate } = this.options;
253
+ const width = optWidth || (height >= 720 ? 1280 : Math.round((height * 16) / 9 / 2) * 2);
254
+ console.log(
255
+ `[stream] envoi ${width}x${height} @ ${fps}fps, ${bitrate ?? '?'}kbps (WebRTC)`,
256
+ );
257
+ playOptions = {
258
+ width,
259
+ height,
260
+ frameRate: fps,
261
+ readrateInitialBurst: 1,
262
+ };
263
+ }
264
+ this.emit('playing');
193
265
 
194
266
  this._playPromise = playStream(output, this.streamer, playOptions, signal);
195
267
 
196
268
  this._playPromise
197
269
  .then(() => {
198
270
  this._running = false;
199
- this._ffmpegProc = null;
271
+ this._ffmpegCommand = null;
200
272
  if (!signal.aborted) {
201
273
  this._positionMs = 0;
202
274
  this.emit('finish');
@@ -204,7 +276,7 @@ class WebRtcStreamSession extends EventEmitter {
204
276
  })
205
277
  .catch(err => {
206
278
  this._running = false;
207
- this._ffmpegProc = null;
279
+ this._ffmpegCommand = null;
208
280
  if (signal.aborted || err?.name === 'AbortError') return;
209
281
  this.emit('error', err);
210
282
  });
@@ -213,66 +285,62 @@ class WebRtcStreamSession extends EventEmitter {
213
285
  }
214
286
 
215
287
  async start() {
216
- const { guildId, channelId, url } = this.options;
217
- await ensureFfmpegPath();
218
- await this.streamer.joinVoice(guildId, channelId);
288
+ const { url } = this.options;
289
+ await this._joinVoice();
290
+ this._started = true;
219
291
 
220
- if (this.client.user?.voice?.channel instanceof StageChannel) {
221
- await this.client.user.voice.setSuppressed(false);
222
- }
292
+ const playing = new Promise((resolve, reject) => {
293
+ this.once('playing', resolve);
294
+ this.once('error', reject);
295
+ });
223
296
 
224
- this._started = true;
225
- void this._run(url, this._abort.signal);
297
+ void this._run(url, this._abort.signal).catch(err => this.emit('error', err));
298
+ await playing;
226
299
  }
227
300
 
228
- /**
229
- * Met en pause la lecture (ffmpeg suspendu, connexion vocale conservée).
230
- */
231
301
  pause() {
232
302
  if (!this._running || this._paused || this._stopped) return;
233
303
  this._positionMs = this._getPositionMs();
234
- this._pausedAt = Date.now();
235
- this._signalFfmpeg('SIGSTOP');
236
304
  this._paused = true;
305
+ this._running = false;
306
+ this._ffmpegCommand?.kill?.('SIGTERM');
307
+ this._ffmpegCommand = null;
308
+ this.streamer.stopStream();
309
+ this._abort.abort();
310
+ this._abort = new AbortController();
311
+ console.log(`[stream] pause à ${(this._positionMs / 1000).toFixed(1)}s`);
237
312
  }
238
313
 
239
- /**
240
- * Reprend la lecture (après pause ou stop).
241
- */
242
314
  async resume() {
243
315
  if (this._stopped) {
244
- const seekSec = this._positionMs / 1000;
245
316
  this._stopped = false;
246
- await this._run(this.options.url, this._abort.signal, seekSec);
317
+ await this._run(this.options.url, this._abort.signal, this._positionMs / 1000);
247
318
  return;
248
319
  }
249
- if (!this._running || !this._paused) return;
250
- this._pausedTotalMs += Date.now() - this._pausedAt;
251
- this._pausedAt = 0;
252
- this._signalFfmpeg('SIGCONT');
320
+ if (!this._paused) return;
253
321
  this._paused = false;
322
+ const seekSec = this._positionMs / 1000;
323
+ console.log(`[stream] reprise à ${seekSec.toFixed(1)}s`);
324
+ await this._run(this.options.url, this._abort.signal, seekSec);
254
325
  }
255
326
 
256
- /**
257
- * Arrête la lecture en conservant la position et la connexion vocale.
258
- */
259
327
  stop() {
260
- if (!this._running) return;
328
+ if (!this._running && !this._paused) return;
261
329
  this._positionMs = this._getPositionMs();
262
330
  this._stopped = true;
263
331
  this._paused = false;
264
332
  this._running = false;
265
- this._ffmpegProc = null;
333
+ this._ffmpegCommand?.kill?.('SIGTERM');
334
+ this._ffmpegCommand = null;
335
+ this.streamer.stopStream();
266
336
  this._abort.abort();
267
337
  this._abort = new AbortController();
338
+ console.log(`[stream] stop à ${(this._positionMs / 1000).toFixed(1)}s`);
268
339
  }
269
340
 
270
- /**
271
- * Relance depuis la dernière position enregistrée.
272
- */
273
341
  async replay() {
274
342
  const seekSec = (this._running ? this._getPositionMs() : this._positionMs) / 1000;
275
- if (this._running) this.stop();
343
+ if (this._running || this._paused) this.stop();
276
344
  this._stopped = false;
277
345
  await this._run(this.options.url, this._abort.signal, seekSec);
278
346
  }
@@ -1215,6 +1215,16 @@ export interface StartStreamOptions {
1215
1215
  livestream?: boolean;
1216
1216
  /** Low-latency encoding and playback. Default true. */
1217
1217
  lowLatency?: boolean;
1218
+ /** Video encoder. Default 'auto' (NVENC if available). */
1219
+ encoder?: 'auto' | 'amf' | 'nvenc' | 'qsv' | 'software';
1220
+ /** GPU decoding via -hwaccel auto. Default true. */
1221
+ hardwareAcceleratedDecoding?: boolean;
1222
+ /** NVENC preset (p1 = lowest latency). Default 'p1'. */
1223
+ nvencPreset?: string;
1224
+ /** Pre-encode locally before playback for smooth speed. Default true. */
1225
+ preEncode?: boolean;
1226
+ /** WebRTC Go Live. Default false (UDP classic, more stable). */
1227
+ goLive?: boolean;
1218
1228
  }
1219
1229
 
1220
1230
  /** Session Go Live WebRTC retournée par {@link Client#startStream}. */