streamify-audio 2.1.16 → 2.1.22

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/README.md CHANGED
@@ -171,23 +171,48 @@ audio.play();
171
171
  | Feature | Discord | HTTP |
172
172
  |---------|:-------:|:----:|
173
173
  | 🎵 YouTube, Spotify, SoundCloud | ✅ | ✅ |
174
+ | 📺 **Voice Channel Status** | ✅ | — |
175
+ | 🔍 **Advanced Search Filters** | ✅ | ✅ |
174
176
  | 📋 Playlists & Albums | ✅ | ✅ |
175
- | 🎚️ 25+ Audio Filters | ✅ | ✅ |
177
+ | 🎚️ 30+ Stackable Filters | ✅ | ✅ |
176
178
  | 🎛️ 15-Band Equalizer | ✅ | ✅ |
177
179
  | 🎨 15 EQ Presets | ✅ | ✅ |
178
180
  | ⏭️ Instant Skip (prefetch) | ✅ | — |
179
181
  | ⏸️ Auto-pause when alone | ✅ | — |
180
182
  | ▶️ Auto-resume on rejoin | ✅ | — |
181
183
  | 🚪 Auto-leave on inactivity | ✅ | — |
182
- | 📻 Autoplay related tracks | ✅ | — |
183
184
  | 🚫 Sponsorblock | ✅ | ✅ |
184
- | 📊 Stream position tracking | ✅ | ✅ |
185
+ | 📊 Timing & Performance Logs | ✅ | ✅ |
185
186
  | 🔌 No Lavalink/Java needed | ✅ | ✅ |
186
187
 
187
188
  ---
188
189
 
189
190
  ## 🎮 Discord Player Features
190
191
 
192
+ ### Voice Channel Status
193
+
194
+ Show what's playing directly in the sidebar of the voice channel.
195
+
196
+ ```javascript
197
+ const manager = new Streamify.Manager(client, {
198
+ voiceChannelStatus: {
199
+ enabled: true,
200
+ template: '🎶 Playing: {title} | {requester}'
201
+ }
202
+ });
203
+ ```
204
+
205
+ ### Search with Filters
206
+
207
+ Filter for live streams or sort results by popularity/date.
208
+
209
+ ```javascript
210
+ const results = await manager.search('lofi hip hop', {
211
+ type: 'live',
212
+ sort: 'popularity'
213
+ });
214
+ ```
215
+
191
216
  ### Queue Management
192
217
 
193
218
  ```javascript
@@ -297,6 +322,10 @@ const url = streamify.getStreamUrl('youtube', 'dQw4w9WgXcQ', {
297
322
  | `volume` | 0 to 200 | Volume % |
298
323
  | `nightcore` | boolean | Speed + pitch up |
299
324
  | `vaporwave` | boolean | Speed + pitch down |
325
+ | `subboost` | boolean | Extreme sub-bass boost |
326
+ | `reverb` | boolean | Room acoustics effect |
327
+ | `surround` | boolean | Surround sound mapping |
328
+ | `boost` | boolean | Clarity & volume boost |
300
329
  | `8d` | boolean | Rotating audio |
301
330
  | `karaoke` | boolean | Reduce vocals |
302
331
  | `bassboost` | boolean | Heavy bass |
@@ -57,10 +57,21 @@ const manager = new Streamify.Manager(client, {
57
57
  autoplay: {
58
58
  enabled: false,
59
59
  maxTracks: 5 // Related tracks to fetch
60
+ },
61
+
62
+ // Voice Channel Status
63
+ voiceChannelStatus: {
64
+ enabled: true, // Show "Now Playing" in VC description
65
+ template: '🎶 Now Playing: {title} - {artist}' // Custom template
60
66
  }
61
67
  });
62
68
  ```
63
69
 
70
+ **Voice Channel Status Template Variables:**
71
+ - `{title}` - Track title
72
+ - `{artist}` - Track artist/author
73
+ - `{requester}` - Username of the requester (if provided)
74
+
64
75
  ## HTTP Server Mode
65
76
 
66
77
  ```javascript
@@ -70,15 +70,22 @@ process.on('SIGINT', () => {
70
70
 
71
71
  Searches for tracks.
72
72
 
73
- ```javascript
74
- // Basic search (YouTube)
75
- const result = await manager.search('never gonna give you up');
73
+ - `query` (string) - The search term or URL
74
+ - `options` (object) - Search configuration
75
+ - `source` (string) - `youtube`, `spotify`, or `soundcloud`
76
+ - `limit` (number) - Number of results (default: 10)
77
+ - `type` (string) - `video`, `live`, or `all` (YouTube only)
78
+ - `sort` (string) - `relevance`, `popularity`, `date`, or `rating` (YouTube only)
79
+
80
+ **Example:**
76
81
 
77
- // With options
78
- const result = await manager.search('never gonna give you up', {
79
- source: 'spotify', // youtube, spotify, soundcloud
80
- limit: 10
82
+ ```javascript
83
+ const result = await manager.search('lofi hip hop', {
84
+ source: 'youtube',
85
+ type: 'live',
86
+ sort: 'popularity'
81
87
  });
88
+ ```
82
89
 
83
90
  // Result
84
91
  {
@@ -163,6 +163,10 @@ const presets = player.getEffectPresets();
163
163
  - `vaporwave` - Slow down with lower pitch
164
164
  - `8d` - 8D rotating audio effect
165
165
  - `karaoke` - Reduce vocals
166
+ - `reverb` - Add room acoustics / echo
167
+ - `surround` - Virtual surround sound mapping
168
+ - `boost` - General volume and clarity boost
169
+ - `subboost` - Extreme sub-woofer boost
166
170
  - `trebleboost` - Boost treble frequencies
167
171
  - `deep` - Deep bass with lower pitch
168
172
  - `lofi` - Lo-fi aesthetic
package/docs/filters.md CHANGED
@@ -287,6 +287,44 @@ await player.setFilter('peaking', {
287
287
  });
288
288
  ```
289
289
 
290
+ ## Effect Presets (Stacked)
291
+
292
+ Streamify supports "Effect Presets" which can be stacked on top of each other. These are more powerful than standard filters because they can combine multiple FFmpeg settings.
293
+
294
+ ### Available Effect Presets
295
+
296
+ | Preset | Description |
297
+ |--------|-------------|
298
+ | `bassboost` | Strong low-end boost |
299
+ | `subboost` | Extreme sub-woofer boost |
300
+ | `nightcore` | High speed and pitch |
301
+ | `vaporwave` | Low speed and pitch |
302
+ | `reverb` | Adds room acoustics / echo |
303
+ | `surround` | Virtual surround sound mapping |
304
+ | `boost` | General volume and clarity boost |
305
+ | `karaoke` | Vocal reduction |
306
+ | `8d` | Fast circular panning |
307
+
308
+ ### Usage
309
+
310
+ ```javascript
311
+ // Set a single effect with intensity (0.0 to 2.0)
312
+ await player.setEffectPresets({ name: 'reverb', intensity: 0.8 });
313
+
314
+ // Stack multiple effects
315
+ await player.setEffectPresets([
316
+ { name: 'bassboost', intensity: 1.2 },
317
+ { name: 'nightcore', intensity: 1.0 }
318
+ ]);
319
+
320
+ // Clear all effects
321
+ await player.clearEffectPresets();
322
+
323
+ // Get active effects
324
+ const active = player.getActiveEffectPresets();
325
+ // [{ name: 'reverb', intensity: 0.8 }]
326
+ ```
327
+
290
328
  ## Additional Effects
291
329
 
292
330
  | Effect | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamify-audio",
3
- "version": "2.1.16",
3
+ "version": "2.1.22",
4
4
  "description": "Dual-mode audio library: HTTP streaming proxy + Discord player (Lavalink alternative). Supports YouTube, Spotify, SoundCloud with audio filters.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/src/config.js CHANGED
@@ -18,6 +18,10 @@ const defaults = {
18
18
  clientId: null,
19
19
  clientSecret: null
20
20
  },
21
+ voiceChannelStatus: {
22
+ enabled: false,
23
+ template: '🎶 Now Playing: {title} - {artist} | Requested by: {requester}'
24
+ },
21
25
  audio: {
22
26
  bitrate: '128k',
23
27
  format: 'opus'
@@ -73,6 +73,11 @@ class Manager extends EventEmitter {
73
73
  maxTracks: options.autoplay?.maxTracks ?? 5
74
74
  };
75
75
 
76
+ this.voiceChannelStatus = {
77
+ enabled: options.voiceChannelStatus?.enabled ?? false,
78
+ template: options.voiceChannelStatus?.template ?? '🎶 Now Playing: {title} - {artist} | Requested by: {requester}'
79
+ };
80
+
76
81
  this._setupVoiceStateListener();
77
82
 
78
83
  log.info('MANAGER', 'Streamify Manager initialized');
@@ -91,6 +96,10 @@ class Manager extends EventEmitter {
91
96
  }
92
97
 
93
98
  if (oldState.id === botId && newState.channelId && oldState.channelId !== newState.channelId) {
99
+ // Clear status of the old channel
100
+ if (oldState.channelId) {
101
+ this.clearVoiceStatus(oldState.channelId);
102
+ }
94
103
  player.voiceChannelId = newState.channelId;
95
104
  player.emit('channelMove', newState.channelId);
96
105
  return;
@@ -103,12 +112,16 @@ class Manager extends EventEmitter {
103
112
  const members = channel.members.filter(m => !m.user.bot);
104
113
  const memberCount = members.size;
105
114
 
106
- if (oldState.channelId === player.voiceChannelId && newState.channelId !== player.voiceChannelId) {
107
- player.emit('userLeave', oldState.member, memberCount);
108
- }
115
+ // Ignore bots for join/leave events
116
+ const transitionedMember = newState.member || oldState.member;
117
+ if (transitionedMember && !transitionedMember.user.bot) {
118
+ if (oldState.channelId === player.voiceChannelId && newState.channelId !== player.voiceChannelId) {
119
+ player.emit('userLeave', transitionedMember, memberCount);
120
+ }
109
121
 
110
- if (newState.channelId === player.voiceChannelId && oldState.channelId !== player.voiceChannelId) {
111
- player.emit('userJoin', newState.member, memberCount);
122
+ if (newState.channelId === player.voiceChannelId && oldState.channelId !== player.voiceChannelId) {
123
+ player.emit('userJoin', transitionedMember, memberCount);
124
+ }
112
125
  }
113
126
 
114
127
  if (memberCount < player.autoPause.minUsers) {
@@ -165,7 +178,8 @@ class Manager extends EventEmitter {
165
178
  maxPreviousTracks: this.maxPreviousTracks,
166
179
  autoLeave: this.autoLeave,
167
180
  autoPause: this.autoPause,
168
- autoplay: this.autoplay
181
+ autoplay: this.autoplay,
182
+ voiceChannelStatus: this.voiceChannelStatus
169
183
  });
170
184
 
171
185
  player.on('destroy', () => {
@@ -184,6 +198,20 @@ class Manager extends EventEmitter {
184
198
  return this.players.get(guildId);
185
199
  }
186
200
 
201
+ async clearVoiceStatus(channelId) {
202
+ if (!channelId) return false;
203
+ try {
204
+ await this.client.rest.put(`/channels/${channelId}/voice-status`, {
205
+ body: { status: "" }
206
+ });
207
+ log.debug('MANAGER', `Cleared voice status for channel ${channelId}`);
208
+ return true;
209
+ } catch (error) {
210
+ log.debug('MANAGER', `Failed to clear voice status for ${channelId}: ${error.message}`);
211
+ return false;
212
+ }
213
+ }
214
+
187
215
  destroy(guildId) {
188
216
  const player = this.players.get(guildId);
189
217
  if (player) {
@@ -65,10 +65,16 @@ class Player extends EventEmitter {
65
65
  maxTracks: options.autoplay?.maxTracks ?? 5
66
66
  };
67
67
 
68
+ this.voiceChannelStatus = {
69
+ enabled: options.voiceChannelStatus?.enabled ?? false,
70
+ template: options.voiceChannelStatus?.template ?? '🎶 Now Playing: {title} - {artist} | Requested by: {requester}'
71
+ };
72
+
68
73
  this._emptyTimeout = null;
69
74
  this._inactivityTimeout = null;
70
75
  this._lastActivity = Date.now();
71
76
  this._autoPaused = false;
77
+ this._lastStatusUpdate = 0;
72
78
  }
73
79
 
74
80
  setAutoPause(enabled) {
@@ -206,6 +212,7 @@ class Player extends EventEmitter {
206
212
  if (this.autoplay.enabled && track) {
207
213
  await this._handleAutoplay(track);
208
214
  } else {
215
+ this._clearVoiceChannelStatus();
209
216
  this.emit('queueEnd');
210
217
  this._resetInactivityTimeout();
211
218
  }
@@ -241,6 +248,7 @@ class Player extends EventEmitter {
241
248
  // Use setImmediate to break recursion stack on consecutive failures
242
249
  setImmediate(() => this._playTrack(next));
243
250
  } else {
251
+ this._clearVoiceChannelStatus();
244
252
  this.emit('queueEnd');
245
253
  }
246
254
  });
@@ -332,6 +340,7 @@ class Player extends EventEmitter {
332
340
  this._positionTimestamp = Date.now();
333
341
 
334
342
  this._prefetchNext();
343
+ setImmediate(() => this._updateVoiceChannelStatus(track));
335
344
 
336
345
  return track;
337
346
  } catch (error) {
@@ -352,6 +361,14 @@ class Player extends EventEmitter {
352
361
  }
353
362
  }
354
363
 
364
+ _clearPrefetch() {
365
+ if (this._prefetchedStream) {
366
+ this._prefetchedStream.destroy();
367
+ this._prefetchedStream = null;
368
+ }
369
+ this._prefetchedTrack = null;
370
+ }
371
+
355
372
  async _prefetchNext() {
356
373
  if (this._prefetching || this.queue.tracks.length === 0) return;
357
374
 
@@ -382,12 +399,57 @@ class Player extends EventEmitter {
382
399
  }
383
400
  }
384
401
 
385
- _clearPrefetch() {
386
- if (this._prefetchedStream) {
387
- this._prefetchedStream.destroy();
388
- this._prefetchedStream = null;
402
+ async _updateVoiceChannelStatus(track, force = false) {
403
+ if (!this.voiceChannelStatus.enabled || !track) return;
404
+
405
+ const now = Date.now();
406
+ if (!force && now - this._lastStatusUpdate < 300000) {
407
+ log.debug('PLAYER', 'Skipping voice channel status update due to rate limit');
408
+ return;
409
+ }
410
+
411
+ const guild = this.manager.client.guilds.cache.get(this.guildId);
412
+ const channel = guild?.channels.cache.get(this.voiceChannelId);
413
+ if (!channel) return;
414
+
415
+ const botMember = guild.members.me || guild.members.cache.get(this.manager.client.user.id);
416
+ if (!channel.permissionsFor(botMember)?.has('ManageChannels')) {
417
+ log.warn('PLAYER', `Missing 'ManageChannels' permission to update status in ${channel.name}`);
418
+ return;
419
+ }
420
+
421
+ try {
422
+ const requesterName = typeof track.requestedBy === 'string' ? track.requestedBy : (track.requestedBy?.username || 'Unknown');
423
+ const statusText = this.voiceChannelStatus.template
424
+ .replace('{title}', track.title || 'Unknown')
425
+ .replace('{artist}', track.author || 'Unknown')
426
+ .replace('{requester}', requesterName)
427
+ .substring(0, 500);
428
+
429
+ log.info('PLAYER', `Updating voice status: ${statusText}`);
430
+
431
+ await this.manager.client.rest.put(`/channels/${this.voiceChannelId}/voice-status`, {
432
+ body: { status: statusText }
433
+ });
434
+
435
+ this._lastStatusUpdate = now;
436
+ log.info('PLAYER', `Voice channel status updated successfully`);
437
+ } catch (error) {
438
+ log.error('PLAYER', `Failed to update voice channel status: ${error.message}`);
439
+ }
440
+ }
441
+
442
+ async _clearVoiceChannelStatus() {
443
+ if (!this.voiceChannelStatus.enabled) return;
444
+
445
+ try {
446
+ await this.manager.client.rest.put(`/channels/${this.voiceChannelId}/voice-status`, {
447
+ body: { status: "" }
448
+ });
449
+ log.info('PLAYER', 'Cleared voice channel status');
450
+ } catch (error) {
451
+ log.error('PLAYER', `Failed to clear voice channel status: ${error.message}`);
389
452
  }
390
- this._prefetchedTrack = null;
391
453
  }
392
454
 
393
455
  async _handleAutoplay(lastTrack) {
@@ -440,6 +502,7 @@ class Player extends EventEmitter {
440
502
 
441
503
  this.audioPlayer.stop(true);
442
504
  log.info('PLAYER', `Paused playback at ${Math.floor(this._positionMs / 1000)}s (Reason: User Request)`);
505
+ this._clearVoiceChannelStatus();
443
506
 
444
507
  return true;
445
508
  }
@@ -466,6 +529,7 @@ class Player extends EventEmitter {
466
529
  this._positionTimestamp = Date.now();
467
530
 
468
531
  this._prefetchNext();
532
+ this._updateVoiceChannelStatus(track, true);
469
533
  } catch (error) {
470
534
  log.error('PLAYER', `Resume failed: ${error.message}`);
471
535
  this._paused = false;
@@ -479,6 +543,7 @@ class Player extends EventEmitter {
479
543
  this.audioPlayer.unpause();
480
544
  this._paused = false;
481
545
  this._positionTimestamp = Date.now();
546
+ this._updateVoiceChannelStatus(this.queue.current, true);
482
547
  }
483
548
 
484
549
  return true;
@@ -551,6 +616,7 @@ class Player extends EventEmitter {
551
616
  this.queue.setCurrent(null);
552
617
 
553
618
  log.info('PLAYER', 'Stopped playback and cleared queue');
619
+ this._clearVoiceChannelStatus();
554
620
 
555
621
  return true;
556
622
  }
@@ -779,6 +845,7 @@ class Player extends EventEmitter {
779
845
  this.queue.clear();
780
846
  this.queue.setCurrent(null);
781
847
 
848
+ this._clearVoiceChannelStatus();
782
849
  this.emit('destroy');
783
850
  this.removeAllListeners();
784
851
  }
@@ -802,4 +869,4 @@ class Player extends EventEmitter {
802
869
  }
803
870
  }
804
871
 
805
- module.exports = Player;
872
+ module.exports = Player;
@@ -21,8 +21,6 @@ class StreamController {
21
21
  this.resource = null;
22
22
  this.destroyed = false;
23
23
  this.startTime = null;
24
- this.bytesReceived = 0;
25
- this.bytesSent = 0;
26
24
  this.ytdlpError = '';
27
25
  this.ffmpegError = '';
28
26
 
@@ -71,15 +69,16 @@ class StreamController {
71
69
 
72
70
  log.info('STREAM', `Creating stream for ${videoId} (${source})`);
73
71
 
74
- // Log Filter Chain
75
- const filterNames = Object.keys(this.filters).filter(k => k !== 'start' && k !== '_trigger');
72
+ // Log Filter Chain Trace (No data impact)
73
+ const filterNames = Object.keys(this.filters).filter(k => k !== 'start' && k !== '_trigger' && k !== 'volume');
76
74
  if (filterNames.length > 0) {
77
75
  const chain = filterNames.map(name => {
78
76
  const val = this.filters[name];
79
- const displayVal = typeof val === 'object' ? JSON.stringify(val) : val;
80
- return `[${name} (${displayVal})]`;
81
- }).join(' -> ');
82
- log.debug('STREAM', `Filter Chain: ${chain}`);
77
+ let displayVal = typeof val === 'object' ? JSON.stringify(val) : val;
78
+ if (displayVal === true || displayVal === 'true') displayVal = 'ON';
79
+ return `[${name.toUpperCase()} (${displayVal})]`;
80
+ }).join(' ');
81
+ log.info('STREAM', `Filter Chain: ${chain}`);
83
82
  }
84
83
 
85
84
  let url;
@@ -112,7 +111,6 @@ class StreamController {
112
111
 
113
112
  if (isLive) {
114
113
  ytdlpArgs.push('--no-live-from-start');
115
- log.info('STREAM', `Live stream detected, using live-compatible format`);
116
114
  } else if (isYouTube) {
117
115
  ytdlpArgs.push('--extractor-args', 'youtube:player_client=web_creator');
118
116
  }
@@ -143,29 +141,8 @@ class StreamController {
143
141
 
144
142
  this.metrics.spawn = Date.now() - spawnStart;
145
143
 
146
- const { pipeline } = require('stream');
147
-
148
- this._firstDataTime = null;
149
-
150
- this.ytdlp.stdout.on('data', (chunk) => {
151
- this.bytesReceived += chunk.length;
152
- });
153
-
154
- this.ffmpeg.stdout.on('data', (chunk) => {
155
- if (!this._firstDataTime) {
156
- this._firstDataTime = Date.now() - this.startTime;
157
- this.metrics.firstByte = Date.now() - (startTimestamp + this.metrics.metadata + this.metrics.spawn);
158
- }
159
- this.bytesSent += chunk.length;
160
- });
161
-
162
- pipeline(this.ytdlp.stdout, this.ffmpeg.stdin, (err) => {
163
- if (err && !this.destroyed) {
164
- if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE' && err.code !== 'EPIPE') {
165
- log.debug('STREAM', `Pipeline error: ${err.message}`);
166
- }
167
- }
168
- });
144
+ // Direct pipe for stability
145
+ this.ytdlp.stdout.pipe(this.ffmpeg.stdin);
169
146
 
170
147
  this.ytdlp.stderr.on('data', (data) => {
171
148
  if (this.destroyed) return;
@@ -190,42 +167,9 @@ class StreamController {
190
167
  this.ytdlp.on('close', (code) => {
191
168
  if (code !== 0 && code !== null && !this.destroyed) {
192
169
  log.error('STREAM', `yt-dlp failed (code: ${code}) for ${videoId}`);
193
- if (this.ytdlpError) {
194
- log.error('STREAM', `yt-dlp stderr: ${this.ytdlpError.slice(-500)}`);
195
- }
196
170
  }
197
171
  if (this.ffmpeg && !this.ffmpeg.killed && this.ffmpeg.stdin) {
198
- this.ffmpeg.stdin.end();
199
- }
200
- });
201
-
202
- this.ytdlp.on('error', (error) => {
203
- if (error.code !== 'EPIPE' && !this.destroyed) {
204
- log.error('STREAM', `yt-dlp spawn error: ${error.message}`);
205
- }
206
- });
207
-
208
- this.ffmpeg.on('error', (error) => {
209
- if (error.code !== 'EPIPE' && !this.destroyed) {
210
- log.error('STREAM', `ffmpeg spawn error: ${error.message}`);
211
- }
212
- });
213
-
214
- this.ytdlp.stdout.on('error', (error) => {
215
- if (error.code !== 'EPIPE' && !this.destroyed) {
216
- log.debug('STREAM', `yt-dlp stdout error: ${error.message}`);
217
- }
218
- });
219
-
220
- this.ffmpeg.stdin.on('error', (error) => {
221
- if (error.code !== 'EPIPE' && !this.destroyed) {
222
- log.debug('STREAM', `ffmpeg stdin error: ${error.message}`);
223
- }
224
- });
225
-
226
- this.ffmpeg.stdout.on('error', (error) => {
227
- if (error.code !== 'EPIPE' && !this.destroyed) {
228
- log.debug('STREAM', `ffmpeg stdout error: ${error.message}`);
172
+ try { this.ffmpeg.stdin.end(); } catch(e) {}
229
173
  }
230
174
  });
231
175
 
@@ -239,7 +183,7 @@ class StreamController {
239
183
  const elapsed = Date.now() - startTimestamp;
240
184
  this.metrics.total = elapsed;
241
185
 
242
- log.info('STREAM', `Ready ${elapsed}ms | Metrics: [Metadata: ${this.metrics.metadata}ms | Spawn: ${this.metrics.spawn}ms | FirstByte: ${this.metrics.firstByte}ms] | Buffered: ${this.bytesSent}b`);
186
+ log.info('STREAM', `Ready ${elapsed}ms | Metrics: [Metadata: ${this.metrics.metadata}ms | Spawn: ${this.metrics.spawn}ms | FirstByte: ${this.metrics.firstByte}ms]`);
243
187
 
244
188
  return this.resource;
245
189
  }
@@ -248,40 +192,40 @@ class StreamController {
248
192
  return new Promise((resolve, reject) => {
249
193
  const timeoutMs = isLive ? 30000 : 15000;
250
194
  const timeout = setTimeout(() => {
251
- log.warn('STREAM', `Timeout waiting for data, proceeding anyway (received: ${this.bytesSent}, isLive: ${isLive})`);
195
+ log.warn('STREAM', `Timeout waiting for data, proceeding anyway`);
252
196
  resolve();
253
197
  }, timeoutMs);
254
198
 
255
199
  let resolved = false;
256
200
 
257
- const checkInterval = setInterval(() => {
258
- if (resolved) {
259
- clearInterval(checkInterval);
260
- return;
261
- }
262
- if (this.bytesSent > 0) {
201
+ // USE READABLE EVENT: Zero-consumption way to detect data
202
+ const onReadable = () => {
203
+ if (!resolved) {
263
204
  resolved = true;
264
205
  clearTimeout(timeout);
265
- clearInterval(checkInterval);
206
+ this.metrics.firstByte = Date.now() - (this.startTime + this.metrics.metadata + this.metrics.spawn);
207
+ this.ffmpeg.stdout.removeListener('readable', onReadable);
266
208
  resolve();
267
209
  }
268
- }, 50);
210
+ };
211
+
212
+ this.ffmpeg.stdout.on('readable', onReadable);
269
213
 
270
214
  this.ffmpeg.on('close', () => {
271
215
  if (!resolved) {
272
216
  resolved = true;
273
217
  clearTimeout(timeout);
274
- clearInterval(checkInterval);
275
- reject(new Error(`ffmpeg closed before producing data. ffmpeg stderr: ${this.ffmpegError.slice(-200) || 'none'} | yt-dlp stderr: ${this.ytdlpError.slice(-200) || 'none'}`));
218
+ this.ffmpeg.stdout.removeListener('readable', onReadable);
219
+ reject(new Error(`ffmpeg closed before producing data. yt-dlp stderr: ${this.ytdlpError.slice(-200) || 'none'}`));
276
220
  }
277
221
  });
278
222
 
279
223
  this.ytdlp.on('close', (code) => {
280
- if (!resolved && code !== 0) {
224
+ if (!resolved && code !== 0 && code !== null) {
281
225
  resolved = true;
282
226
  clearTimeout(timeout);
283
- clearInterval(checkInterval);
284
- reject(new Error(`yt-dlp failed with code ${code}. stderr: ${this.ytdlpError.slice(-200) || 'none'}`));
227
+ this.ffmpeg.stdout.removeListener('readable', onReadable);
228
+ reject(new Error(`yt-dlp failed with code ${code}`));
285
229
  }
286
230
  });
287
231
  });
@@ -292,7 +236,7 @@ class StreamController {
292
236
  this.destroyed = true;
293
237
 
294
238
  const elapsed = this.startTime ? Date.now() - this.startTime : 0;
295
- log.info('STREAM', `Destroying stream | Duration: ${elapsed}ms | Data Out: ${(this.bytesSent / 1024 / 1024).toFixed(2)} MB`);
239
+ log.info('STREAM', `Destroying stream | Duration: ${Math.floor(elapsed / 1000)}s`);
296
240
 
297
241
  try {
298
242
  if (this.ytdlp && this.ffmpeg) {