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 +32 -3
- package/docs/configuration.md +11 -0
- package/docs/discord/manager.md +14 -7
- package/docs/discord/player.md +4 -0
- package/docs/filters.md +38 -0
- package/package.json +1 -1
- package/src/config.js +4 -0
- package/src/discord/Manager.js +34 -6
- package/src/discord/Player.js +73 -6
- package/src/discord/Stream.js +26 -82
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
|
-
| 🎚️
|
|
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
|
-
| 📊
|
|
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 |
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
package/docs/discord/manager.md
CHANGED
|
@@ -70,15 +70,22 @@ process.on('SIGINT', () => {
|
|
|
70
70
|
|
|
71
71
|
Searches for tracks.
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
const result = await manager.search('
|
|
79
|
-
source: '
|
|
80
|
-
|
|
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
|
{
|
package/docs/discord/player.md
CHANGED
|
@@ -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.
|
|
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
package/src/discord/Manager.js
CHANGED
|
@@ -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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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) {
|
package/src/discord/Player.js
CHANGED
|
@@ -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
|
-
|
|
386
|
-
if (this.
|
|
387
|
-
|
|
388
|
-
|
|
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;
|
package/src/discord/Stream.js
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
275
|
-
reject(new Error(`ffmpeg closed before producing data.
|
|
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
|
-
|
|
284
|
-
reject(new Error(`yt-dlp failed with code ${code}
|
|
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
|
|
239
|
+
log.info('STREAM', `Destroying stream | Duration: ${Math.floor(elapsed / 1000)}s`);
|
|
296
240
|
|
|
297
241
|
try {
|
|
298
242
|
if (this.ytdlp && this.ffmpeg) {
|