djs-selfbot-v13 3.7.36 → 3.7.37

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.37",
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,165 @@ 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
- }
101
+ async _pickEncoder() {
102
+ const { preset = 'ultrafast', encoder = 'auto' } = this.options;
103
+ let mode = encoder;
104
+ if (encoder === 'auto') mode = await detectHwEncoder();
78
105
 
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
106
+ if (mode === 'nvenc') {
107
+ console.log('[stream] encodeur: h264_nvenc');
108
+ return Encoders.nvenc({ preset: 'p1' });
109
+ }
110
+ if (mode === 'amf') {
111
+ console.log('[stream] encodeur: h264_amf');
112
+ return () => ({
113
+ H264: {
114
+ name: 'h264_amf',
115
+ options: ['-usage', 'transcoding', '-quality', 'speed'],
116
+ },
117
+ H265: {
118
+ name: 'hevc_amf',
119
+ options: ['-usage', 'transcoding', '-quality', 'speed'],
120
+ },
121
+ });
86
122
  }
123
+ console.log('[stream] encodeur: libx264');
124
+ return Encoders.software({
125
+ x264: { preset, tune: 'zerolatency' },
126
+ });
87
127
  }
88
128
 
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) {
129
+ async _buildPrepareOptions(seekSec = 0) {
94
130
  const {
95
- fps,
96
- height = 720,
97
- width = -2,
98
- bitrate = 5000,
131
+ fps = 60,
132
+ height,
133
+ width,
134
+ bitrate = 4500,
99
135
  bitrateMax,
100
136
  audioBitrate = 128,
101
- preset = 'superfast',
102
- tune = 'film',
137
+ hardwareAcceleratedDecoding = true,
103
138
  audio,
139
+ noTranscoding = false,
104
140
  } = this.options;
105
141
 
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
- ]);
142
+ if (noTranscoding) {
143
+ console.log('[stream] mode copie vidéo (pas de ré-encodage)');
144
+ return {
145
+ noTranscoding: true,
146
+ bitrateAudio: audioBitrate,
147
+ includeAudio: audio !== false,
148
+ hardwareAcceleratedDecoding: false,
149
+ minimizeLatency: true,
150
+ customInputOptions: seekSec > 0 ? [`-ss ${String(seekSec)}`] : [],
151
+ customFfmpegFlags: [],
152
+ };
121
153
  }
122
154
 
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');
155
+ const gop = String(fps);
156
+
157
+ return {
158
+ encoder: await this._pickEncoder(),
159
+ height,
160
+ width,
161
+ frameRate: fps,
162
+ bitrateVideo: bitrate,
163
+ bitrateVideoMax: bitrateMax ?? Math.round(bitrate * 1.5),
164
+ bitrateAudio: audioBitrate,
165
+ includeAudio: audio !== false,
166
+ hardwareAcceleratedDecoding,
167
+ minimizeLatency: true,
168
+ customInputOptions: seekSec > 0 ? [`-ss ${String(seekSec)}`] : [],
169
+ customFfmpegFlags: [
170
+ '-g', gop,
171
+ '-keyint_min', gop,
172
+ '-sc_threshold', '0',
173
+ '-threads', '0',
174
+ ],
175
+ };
176
+ }
177
+
178
+ async _disconnectLegacyVoice() {
179
+ const conn = this.client.voice?.connection;
180
+ if (conn) {
181
+ conn.disconnect();
182
+ this.client.voice.connection = null;
183
+ await sleep(800);
153
184
  }
185
+ }
154
186
 
155
- return { command, output };
187
+ async _joinVoice() {
188
+ const { guildId, channelId } = this.options;
189
+
190
+ if (this.streamer.voiceConnection) {
191
+ this.streamer.leaveVoice();
192
+ await sleep(1000);
193
+ }
194
+
195
+ await this._disconnectLegacyVoice();
196
+
197
+ const vc = this.streamer.voiceConnection;
198
+ if (
199
+ !vc ||
200
+ vc.guildId !== guildId ||
201
+ vc.channelId !== channelId
202
+ ) {
203
+ await this.streamer.joinVoice(guildId, channelId);
204
+ }
205
+
206
+ if (this.client.user?.voice?.channel instanceof StageChannel) {
207
+ await this.client.user.voice.setSuppressed(false);
208
+ }
156
209
  }
157
210
 
158
211
  async _run(url, signal, seekSec = 0) {
159
- await ensureFfmpegPath();
160
-
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();
212
+ const prepareOpts = await this._buildPrepareOptions(seekSec);
213
+ const { command, output } = prepareStream(url, prepareOpts, signal);
214
+ this._ffmpegCommand = command;
168
215
 
169
- command.on('start', cmd => {
170
- this._ffmpegProc = command.ffmpegProc ?? null;
171
- this.emit('debug', `[cmd] ${cmd}`);
172
- });
216
+ command.on('start', cmd => this.emit('debug', `[ffmpeg] ${cmd}`));
173
217
  command.on('stderr', line => this.emit('debug', String(line)));
174
-
175
218
  command.on('error', (err, _stdout, stderr) => {
176
- if (stderr) this.emit('debug', String(stderr));
177
219
  if (signal.aborted) return;
178
- const msg = err?.message || String(err);
179
- this.emit('error', new Error(stderr ? `${msg}\n${stderr}` : msg));
220
+ this.emit('error', new Error(stderr ? `${err.message}\n${stderr}` : err.message));
180
221
  });
181
222
 
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();
223
+ this._running = true;
224
+ this._paused = false;
225
+ this._stopped = false;
226
+ this._runStartWall = Date.now();
189
227
 
190
- const { livestream } = this.options;
191
- const playOptions = { type: 'go-live' };
192
- if (livestream) playOptions.readrateInitialBurst = 10;
228
+ let playOptions;
229
+ if (this.options.noTranscoding) {
230
+ console.log('[stream] envoi à la résolution/fps natifs de la source (WebRTC)');
231
+ playOptions = {};
232
+ } else {
233
+ const { height = 720, width = 1280, fps = 60 } = this.options;
234
+ console.log(`[stream] envoi ${width}x${height} @ ${fps}fps (WebRTC)`);
235
+ playOptions = { width, height, frameRate: fps };
236
+ }
237
+ this.emit('playing');
193
238
 
194
239
  this._playPromise = playStream(output, this.streamer, playOptions, signal);
195
240
 
196
241
  this._playPromise
197
242
  .then(() => {
198
243
  this._running = false;
199
- this._ffmpegProc = null;
244
+ this._ffmpegCommand = null;
200
245
  if (!signal.aborted) {
201
246
  this._positionMs = 0;
202
247
  this.emit('finish');
@@ -204,7 +249,7 @@ class WebRtcStreamSession extends EventEmitter {
204
249
  })
205
250
  .catch(err => {
206
251
  this._running = false;
207
- this._ffmpegProc = null;
252
+ this._ffmpegCommand = null;
208
253
  if (signal.aborted || err?.name === 'AbortError') return;
209
254
  this.emit('error', err);
210
255
  });
@@ -213,66 +258,62 @@ class WebRtcStreamSession extends EventEmitter {
213
258
  }
214
259
 
215
260
  async start() {
216
- const { guildId, channelId, url } = this.options;
217
- await ensureFfmpegPath();
218
- await this.streamer.joinVoice(guildId, channelId);
261
+ const { url } = this.options;
262
+ await this._joinVoice();
263
+ this._started = true;
219
264
 
220
- if (this.client.user?.voice?.channel instanceof StageChannel) {
221
- await this.client.user.voice.setSuppressed(false);
222
- }
265
+ const playing = new Promise((resolve, reject) => {
266
+ this.once('playing', resolve);
267
+ this.once('error', reject);
268
+ });
223
269
 
224
- this._started = true;
225
- void this._run(url, this._abort.signal);
270
+ void this._run(url, this._abort.signal).catch(err => this.emit('error', err));
271
+ await playing;
226
272
  }
227
273
 
228
- /**
229
- * Met en pause la lecture (ffmpeg suspendu, connexion vocale conservée).
230
- */
231
274
  pause() {
232
275
  if (!this._running || this._paused || this._stopped) return;
233
276
  this._positionMs = this._getPositionMs();
234
- this._pausedAt = Date.now();
235
- this._signalFfmpeg('SIGSTOP');
236
277
  this._paused = true;
278
+ this._running = false;
279
+ this._ffmpegCommand?.kill?.('SIGTERM');
280
+ this._ffmpegCommand = null;
281
+ this.streamer.stopStream();
282
+ this._abort.abort();
283
+ this._abort = new AbortController();
284
+ console.log(`[stream] pause à ${(this._positionMs / 1000).toFixed(1)}s`);
237
285
  }
238
286
 
239
- /**
240
- * Reprend la lecture (après pause ou stop).
241
- */
242
287
  async resume() {
243
288
  if (this._stopped) {
244
- const seekSec = this._positionMs / 1000;
245
289
  this._stopped = false;
246
- await this._run(this.options.url, this._abort.signal, seekSec);
290
+ await this._run(this.options.url, this._abort.signal, this._positionMs / 1000);
247
291
  return;
248
292
  }
249
- if (!this._running || !this._paused) return;
250
- this._pausedTotalMs += Date.now() - this._pausedAt;
251
- this._pausedAt = 0;
252
- this._signalFfmpeg('SIGCONT');
293
+ if (!this._paused) return;
253
294
  this._paused = false;
295
+ const seekSec = this._positionMs / 1000;
296
+ console.log(`[stream] reprise à ${seekSec.toFixed(1)}s`);
297
+ await this._run(this.options.url, this._abort.signal, seekSec);
254
298
  }
255
299
 
256
- /**
257
- * Arrête la lecture en conservant la position et la connexion vocale.
258
- */
259
300
  stop() {
260
- if (!this._running) return;
301
+ if (!this._running && !this._paused) return;
261
302
  this._positionMs = this._getPositionMs();
262
303
  this._stopped = true;
263
304
  this._paused = false;
264
305
  this._running = false;
265
- this._ffmpegProc = null;
306
+ this._ffmpegCommand?.kill?.('SIGTERM');
307
+ this._ffmpegCommand = null;
308
+ this.streamer.stopStream();
266
309
  this._abort.abort();
267
310
  this._abort = new AbortController();
311
+ console.log(`[stream] stop à ${(this._positionMs / 1000).toFixed(1)}s`);
268
312
  }
269
313
 
270
- /**
271
- * Relance depuis la dernière position enregistrée.
272
- */
273
314
  async replay() {
274
315
  const seekSec = (this._running ? this._getPositionMs() : this._positionMs) / 1000;
275
- if (this._running) this.stop();
316
+ if (this._running || this._paused) this.stop();
276
317
  this._stopped = false;
277
318
  await this._run(this.options.url, this._abort.signal, seekSec);
278
319
  }
@@ -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}. */