streamify-audio 2.2.14 → 2.3.1

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/index.d.ts CHANGED
@@ -203,6 +203,15 @@ declare module 'streamify-audio' {
203
203
  enabled?: boolean;
204
204
  maxTracks?: number;
205
205
  };
206
+ stream?: {
207
+ dataTimeout?: number;
208
+ pipeDataTimeout?: number;
209
+ liveDataTimeout?: number;
210
+ extractTimeout?: number;
211
+ urlCacheTTL?: number;
212
+ bufferSize?: string;
213
+ maxRetries?: number;
214
+ };
206
215
  }
207
216
 
208
217
  export interface ManagerStats {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamify-audio",
3
- "version": "2.2.14",
3
+ "version": "2.3.1",
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
@@ -38,6 +38,19 @@ const defaults = {
38
38
  searchTTL: 300,
39
39
  infoTTL: 3600,
40
40
  redis: null
41
+ },
42
+ stream: {
43
+ dataTimeout: 8000,
44
+ pipeDataTimeout: 20000,
45
+ liveDataTimeout: 15000,
46
+ extractTimeout: 30000,
47
+ urlCacheTTL: 1800,
48
+ bufferSize: '1M',
49
+ maxRetries: 2
50
+ },
51
+ sponsorblock: {
52
+ enabled: false,
53
+ categories: ['sponsor', 'selfpromo']
41
54
  }
42
55
  };
43
56
 
@@ -107,6 +120,16 @@ function load(options = {}) {
107
120
  ...defaults.cache,
108
121
  ...fileConfig.cache,
109
122
  ...options.cache
123
+ },
124
+ stream: {
125
+ ...defaults.stream,
126
+ ...fileConfig.stream,
127
+ ...options.stream
128
+ },
129
+ sponsorblock: {
130
+ ...defaults.sponsorblock,
131
+ ...fileConfig.sponsorblock,
132
+ ...options.sponsorblock
110
133
  }
111
134
  };
112
135
 
@@ -403,7 +403,7 @@ class Manager extends EventEmitter {
403
403
  }
404
404
 
405
405
  async _resolveSpotifyToYouTube(track) {
406
- const videoId = await spotify.resolveToYouTube(track.id, this.config);
406
+ const videoId = await spotify.resolveToYouTube(track.id, this.config, track);
407
407
  return videoId;
408
408
  }
409
409
 
@@ -317,22 +317,23 @@ class Player extends EventEmitter {
317
317
  this.emit('trackStart', track);
318
318
 
319
319
  let newStream = null;
320
+ let resource = null;
321
+
320
322
  try {
321
323
  const filtersWithVolume = { ...this._filters, volume: this._volume };
322
324
 
323
- if (startPosition === 0 && this._prefetchedTrack?.id === track.id && this._prefetchedStream) {
324
- log.debug('PLAYER', `Using prefetched stream for ${track.id}`);
325
+ if (startPosition === 0 && this._prefetchedTrack?.id === track.id && this._prefetchedStream?.resource) {
326
+ log.info('PLAYER', `Using prefetched stream for ${track.id}`);
325
327
  newStream = this._prefetchedStream;
328
+ resource = newStream.resource;
326
329
  this._prefetchedStream = null;
327
330
  this._prefetchedTrack = null;
328
331
  } else {
329
- // If not using prefetch, we don't clear it yet in case we need to fallback
332
+ this._clearPrefetch();
330
333
  newStream = createStream(track, filtersWithVolume, this.config);
334
+ resource = await newStream.start(startPosition);
331
335
  }
332
336
 
333
- const resource = await newStream.create(startPosition);
334
-
335
- // SEAMLESS SWAP: Only destroy old stream AFTER the new one is ready
336
337
  const oldStream = this.stream;
337
338
  this.stream = newStream;
338
339
  this.audioPlayer.play(resource);
@@ -362,7 +363,7 @@ class Player extends EventEmitter {
362
363
 
363
364
  log.error('PLAYER', `Failed to play track: ${error.message}`);
364
365
  this.emit('trackError', track, error);
365
-
366
+
366
367
  const next = this.queue.shift();
367
368
  if (next) {
368
369
  setImmediate(() => this._playTrack(next));
@@ -384,19 +385,35 @@ class Player extends EventEmitter {
384
385
  if (this._prefetching || this.queue.tracks.length === 0) return;
385
386
 
386
387
  const nextTrack = this.queue.tracks[0];
387
- if (!nextTrack || nextTrack._directUrl) return; // Already prefetched or resolved
388
+ if (!nextTrack) return;
389
+
390
+ if (this._prefetchedTrack?.id === nextTrack.id && this._prefetchedStream) return;
388
391
 
392
+ this._clearPrefetch();
389
393
  this._prefetching = true;
390
- log.debug('PLAYER', `Prefetching metadata: ${nextTrack.title}`);
394
+ log.debug('PLAYER', `Prefetching stream: ${nextTrack.title}`);
391
395
 
392
396
  try {
393
- // Only resolve the metadata and stream URL
394
- const result = await this.manager.getInfo(nextTrack.id, nextTrack.source);
395
- if (result) {
396
- // Merge prefetch data into the existing queue object
397
- Object.assign(nextTrack, result);
398
- log.debug('PLAYER', `Prefetch ready (Metadata only): ${nextTrack.id}`);
397
+ const filtersWithVolume = { ...this._filters, volume: this._volume };
398
+ const stream = createStream(nextTrack, filtersWithVolume, this.config);
399
+
400
+ await stream.prepare();
401
+
402
+ if (this._destroyed || this.queue.tracks[0]?.id !== nextTrack.id) {
403
+ stream.destroy();
404
+ return;
405
+ }
406
+
407
+ await stream.start(0);
408
+
409
+ if (this._destroyed || this.queue.tracks[0]?.id !== nextTrack.id) {
410
+ stream.destroy();
411
+ return;
399
412
  }
413
+
414
+ this._prefetchedStream = stream;
415
+ this._prefetchedTrack = nextTrack;
416
+ log.info('PLAYER', `Prefetch ready: ${nextTrack.title} (${stream.metrics.total}ms)`);
400
417
  } catch (error) {
401
418
  log.debug('PLAYER', `Prefetch failed: ${error.message}`);
402
419
  } finally {
@@ -651,6 +668,7 @@ class Player extends EventEmitter {
651
668
  }
652
669
 
653
670
  async setVolume(volume) {
671
+ this._clearPrefetch();
654
672
  const oldVolume = this._volume;
655
673
  this._volume = Math.max(0, Math.min(200, volume));
656
674
  log.info('PLAYER', `Volume adjusted: ${oldVolume}% -> ${this._volume}%`);
@@ -689,6 +707,7 @@ class Player extends EventEmitter {
689
707
  }
690
708
 
691
709
  async setFilter(name, value) {
710
+ this._clearPrefetch();
692
711
  this._filters[name] = value;
693
712
 
694
713
  if (this._playing && this.queue.current) {
@@ -787,6 +806,7 @@ class Player extends EventEmitter {
787
806
  }
788
807
  }
789
808
 
809
+ this._clearPrefetch();
790
810
  log.info('PLAYER', `Active effects: ${this._effectPresets.map(p => p.name).join(' + ') || 'NONE'}`);
791
811
 
792
812
  if (this._playing && this.queue.current) {
@@ -11,6 +11,50 @@ try {
11
11
 
12
12
  const { createAudioResource, StreamType } = voiceModule || {};
13
13
 
14
+ class StreamURLCache {
15
+ constructor(defaultTTL = 1800000) {
16
+ this._cache = new Map();
17
+ this._defaultTTL = defaultTTL;
18
+ this._cleanupInterval = setInterval(() => this._cleanup(), 300000);
19
+ if (this._cleanupInterval.unref) this._cleanupInterval.unref();
20
+ }
21
+
22
+ get(key) {
23
+ const entry = this._cache.get(key);
24
+ if (!entry) return null;
25
+ if (Date.now() > entry.expiry) {
26
+ this._cache.delete(key);
27
+ return null;
28
+ }
29
+ return { url: entry.url, headers: entry.headers };
30
+ }
31
+
32
+ set(key, url, headers = null, ttl = this._defaultTTL) {
33
+ this._cache.set(key, {
34
+ url,
35
+ headers,
36
+ expiry: Date.now() + ttl
37
+ });
38
+ }
39
+
40
+ invalidate(key) {
41
+ this._cache.delete(key);
42
+ }
43
+
44
+ get size() {
45
+ return this._cache.size;
46
+ }
47
+
48
+ _cleanup() {
49
+ const now = Date.now();
50
+ for (const [key, entry] of this._cache) {
51
+ if (now > entry.expiry) this._cache.delete(key);
52
+ }
53
+ }
54
+ }
55
+
56
+ const urlCache = new StreamURLCache();
57
+
14
58
  class StreamController {
15
59
  constructor(track, filters, config) {
16
60
  this.track = track;
@@ -23,54 +67,120 @@ class StreamController {
23
67
  this.startTime = null;
24
68
  this.ytdlpError = '';
25
69
  this.ffmpegError = '';
26
-
27
- // Timing metrics
70
+
71
+ this._videoId = null;
72
+ this._directStreamUrl = null;
73
+ this._directStreamHeaders = null;
74
+ this._useDirectUrl = false;
75
+ this._prepared = false;
76
+ this._ffmpegSpawnTime = null;
77
+
28
78
  this.metrics = {
29
79
  metadata: 0,
80
+ urlExtract: 0,
30
81
  spawn: 0,
31
82
  firstByte: 0,
32
83
  total: 0
33
84
  };
34
85
  }
35
86
 
36
- async create(seekPosition = 0) {
37
- if (this.destroyed) {
38
- throw new Error('Stream already destroyed');
39
- }
40
-
41
- if (this.resource) {
42
- return this.resource;
43
- }
87
+ async prepare() {
88
+ if (this.destroyed) throw new Error('Stream already destroyed');
44
89
 
45
90
  this.startTime = Date.now();
46
- const startTimestamp = this.startTime;
47
91
  const source = this.track.source || 'youtube';
48
-
49
92
  let videoId = this.track._resolvedId || this.track.id;
50
93
 
51
- // Skip metadata resolution if we already have it
52
94
  if (source === 'spotify' && !this.track._resolvedId) {
53
95
  log.info('STREAM', `Resolving Spotify track to YouTube: ${this.track.title}`);
54
96
  try {
55
97
  const spotify = require('../providers/spotify');
56
- videoId = await spotify.resolveToYouTube(this.track.id, this.config);
98
+ videoId = await spotify.resolveToYouTube(this.track.id, this.config, this.track);
57
99
  this.track._resolvedId = videoId;
58
- this.metrics.metadata = Date.now() - startTimestamp;
100
+ this.metrics.metadata = Date.now() - this.startTime;
59
101
  } catch (error) {
60
- log.error('STREAM', `Spotify resolution failed: ${error.message}`);
61
102
  throw new Error(`Failed to resolve Spotify track: ${error.message}`);
62
103
  }
63
104
  } else {
64
- this.metrics.metadata = 0; // Already resolved
105
+ this.metrics.metadata = 0;
65
106
  }
66
107
 
67
108
  if (!videoId || videoId === 'undefined') {
68
- throw new Error(`Invalid track ID: ${videoId} (source: ${source}, title: ${this.track.title})`);
109
+ throw new Error(`Invalid track ID: ${videoId} (source: ${source})`);
110
+ }
111
+
112
+ this._videoId = videoId;
113
+ const isYouTube = source === 'youtube' || source === 'spotify';
114
+ const isLive = this.track.isLive === true || this.track.duration === 0;
115
+ const isLocal = source === 'local';
116
+
117
+ if (isLocal || isLive || !isYouTube) {
118
+ this._useDirectUrl = false;
119
+ this._prepared = true;
120
+ return;
69
121
  }
70
122
 
71
- log.info('STREAM', `Creating stream for ${videoId} (${source})`);
72
-
73
- // Log Filter Chain Trace (No data impact)
123
+ const cacheKey = `${videoId}:${this.config.ytdlp.format}`;
124
+ const cached = urlCache.get(cacheKey);
125
+
126
+ if (cached) {
127
+ log.info('STREAM', `URL cache hit for ${videoId}`);
128
+ this._directStreamUrl = cached.url;
129
+ this._directStreamHeaders = cached.headers;
130
+ this._useDirectUrl = true;
131
+ this._prepared = true;
132
+ return;
133
+ }
134
+
135
+ const extractStart = Date.now();
136
+ const maxRetries = this.config.stream?.maxRetries || 2;
137
+
138
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
139
+ try {
140
+ const extractedUrl = await this._extractUrl(videoId, this.config, isYouTube);
141
+ this.metrics.urlExtract = Date.now() - extractStart;
142
+
143
+ const ttl = (this.config.stream?.urlCacheTTL || 1800) * 1000;
144
+ urlCache.set(cacheKey, extractedUrl, this.track._headers, ttl);
145
+
146
+ this._directStreamUrl = extractedUrl;
147
+ this._directStreamHeaders = this.track._headers;
148
+ this._useDirectUrl = true;
149
+ this._prepared = true;
150
+
151
+ log.info('STREAM', `URL extracted for ${videoId} (${this.metrics.urlExtract}ms, attempt ${attempt})`);
152
+ return;
153
+ } catch (error) {
154
+ log.warn('STREAM', `URL extraction attempt ${attempt}/${maxRetries} failed: ${error.message}`);
155
+ if (attempt === maxRetries) {
156
+ log.warn('STREAM', `All extraction attempts failed for ${videoId}, falling back to pipe mode`);
157
+ this._useDirectUrl = false;
158
+ this._prepared = true;
159
+ return;
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ async start(seekPosition = 0) {
166
+ if (this.destroyed) throw new Error('Stream already destroyed');
167
+ if (this.resource) return this.resource;
168
+
169
+ if (!this._prepared) {
170
+ await this.prepare();
171
+ }
172
+
173
+ if (!this.startTime) this.startTime = Date.now();
174
+ const startTimestamp = this.startTime;
175
+
176
+ const videoId = this._videoId || this.track._resolvedId || this.track.id;
177
+ const source = this.track.source || 'youtube';
178
+ const isLive = this.track.isLive === true || this.track.duration === 0;
179
+ const isLocal = source === 'local';
180
+ const isYouTube = source === 'youtube' || source === 'spotify';
181
+
182
+ log.info('STREAM', `Creating stream for ${videoId} (${source}, mode: ${this._useDirectUrl ? 'direct' : isLocal ? 'local' : 'pipe'})`);
183
+
74
184
  const filterNames = Object.keys(this.filters).filter(k => k !== 'start' && k !== '_trigger' && k !== 'volume');
75
185
  if (filterNames.length > 0) {
76
186
  const chain = filterNames.map(name => {
@@ -78,30 +188,14 @@ class StreamController {
78
188
  let displayVal = typeof val === 'object' ? JSON.stringify(val) : val;
79
189
  if (displayVal === true || displayVal === 'true') displayVal = 'ON';
80
190
  return `[${name.toUpperCase()} (${displayVal})]`;
81
- }).join(' ');
191
+ }).join(' > ');
82
192
  log.info('STREAM', `Filter Chain: ${chain}`);
83
193
  }
84
194
 
85
- const isYouTube = source === 'youtube' || source === 'spotify';
86
- const isLive = this.track.isLive === true || this.track.duration === 0;
87
- const isLocal = source === 'local';
88
-
89
- let ytdlp;
90
- let ffmpegIn;
91
-
92
195
  const env = { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH };
93
- const spawnStart = Date.now();
196
+ const bufferSize = this.config.stream?.bufferSize || '1M';
94
197
 
95
- if (isLocal) {
96
- // Skip yt-dlp for local files
97
- ffmpegIn = 'pipe:0'; // We'll just pass the file path to ffmpeg -i
98
- this.metrics.spawn = Date.now() - spawnStart;
99
- } else if (this.track._directUrl && !isLive && seekPosition === 0) {
100
- // OPTIMIZATION: Bypass yt-dlp if we already have a direct stream URL
101
- ffmpegIn = this.track._directUrl;
102
- log.info('STREAM', `Bypassing yt-dlp, using direct URL for ${videoId}`);
103
- this.metrics.spawn = 0;
104
- } else {
198
+ if (!this._useDirectUrl && !isLocal) {
105
199
  let url;
106
200
  if (source === 'soundcloud') {
107
201
  url = this.track.uri || `https://api.soundcloud.com/tracks/${videoId}/stream`;
@@ -111,13 +205,7 @@ class StreamController {
111
205
  url = `https://www.youtube.com/watch?v=${videoId}`;
112
206
  }
113
207
 
114
- let formatString;
115
- if (isLive) {
116
- formatString = 'bestaudio*/best';
117
- } else {
118
- formatString = this.config.ytdlp.format;
119
- }
120
-
208
+ const formatString = isLive ? 'bestaudio*/best' : this.config.ytdlp.format;
121
209
  const ytdlpArgs = [
122
210
  '-f', formatString,
123
211
  '--no-playlist',
@@ -125,7 +213,7 @@ class StreamController {
125
213
  '--no-warnings',
126
214
  '--no-cache-dir',
127
215
  '--no-mtime',
128
- '--buffer-size', '256K',
216
+ '--buffer-size', bufferSize,
129
217
  '--quiet',
130
218
  '--retries', '3',
131
219
  '--fragment-retries', '3',
@@ -150,19 +238,17 @@ class StreamController {
150
238
  ytdlpArgs.unshift('--cookies', this.config.cookiesPath);
151
239
  }
152
240
 
153
- if (this.config.sponsorblock?.enabled !== false && isYouTube) {
241
+ if (this.config.sponsorblock?.enabled === true && isYouTube) {
154
242
  const categories = this.config.sponsorblock?.categories || ['sponsor', 'selfpromo'];
155
243
  ytdlpArgs.push('--sponsorblock-remove', categories.join(','));
156
244
  }
157
245
 
158
246
  this.ytdlp = spawn(this.config.ytdlpPath, ytdlpArgs, { env });
159
- ffmpegIn = 'pipe:0';
160
247
  }
161
248
 
162
249
  const ffmpegFilters = { ...this.filters };
163
250
  const ffmpegArgs = buildFfmpegArgs(ffmpegFilters, this.config);
164
-
165
- // Input injection - only override if NOT using pipe:0 (buildFfmpegArgs already includes -i pipe:0)
251
+
166
252
  if (isLocal) {
167
253
  const filePath = this.track.absolutePath || videoId.replace('file://', '');
168
254
  const pipeIndex = ffmpegArgs.indexOf('pipe:0');
@@ -173,23 +259,29 @@ class StreamController {
173
259
  ffmpegArgs.splice(pipeIndex - 1, 0, '-ss', seekSeconds);
174
260
  }
175
261
  }
176
- } else if (ffmpegIn !== 'pipe:0') {
177
- // Direct URL - replace the pipe:0 input with the direct URL
262
+ } else if (this._useDirectUrl) {
178
263
  const pipeIndex = ffmpegArgs.indexOf('pipe:0');
179
264
  if (pipeIndex > 0) {
180
- ffmpegArgs[pipeIndex] = ffmpegIn;
181
- if (this.track._headers) {
182
- const headers = Object.entries(this.track._headers)
265
+ if (seekPosition > 0) {
266
+ const seekSeconds = (seekPosition / 1000).toFixed(3);
267
+ ffmpegArgs.splice(pipeIndex - 1, 0, '-ss', seekSeconds);
268
+ }
269
+
270
+ const newPipeIndex = ffmpegArgs.indexOf('pipe:0');
271
+ ffmpegArgs[newPipeIndex] = this._directStreamUrl;
272
+
273
+ if (this._directStreamHeaders) {
274
+ const headers = Object.entries(this._directStreamHeaders)
183
275
  .map(([k, v]) => `${k}: ${v}`)
184
276
  .join('\r\n');
185
- ffmpegArgs.splice(pipeIndex - 1, 0, '-headers', headers);
277
+ ffmpegArgs.splice(newPipeIndex - 1, 0, '-headers', headers);
186
278
  }
187
279
  }
188
280
  }
189
- // else: pipe:0 - buildFfmpegArgs already has -i pipe:0, nothing to change
190
281
 
282
+ this._ffmpegSpawnTime = Date.now();
191
283
  this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs, { env });
192
- this.metrics.spawn = Date.now() - spawnStart;
284
+ this.metrics.spawn = Date.now() - this._ffmpegSpawnTime;
193
285
 
194
286
  if (this.ytdlp) {
195
287
  this.ffmpeg.stdin.on('error', (err) => {
@@ -212,8 +304,6 @@ class StreamController {
212
304
  this.ytdlpError += msg;
213
305
  if (msg.includes('ERROR:') && !msg.includes('Retrying') && !msg.includes('Broken pipe')) {
214
306
  log.error('YTDLP', msg.trim());
215
- } else if (!msg.includes('[download]') && !msg.includes('ETA') && !msg.includes('[youtube]') && !msg.includes('Retrying fragment') && !msg.includes('Got error')) {
216
- log.debug('YTDLP', msg.trim());
217
307
  }
218
308
  });
219
309
 
@@ -236,7 +326,17 @@ class StreamController {
236
326
  }
237
327
  });
238
328
 
239
- await this._waitForData(isLive);
329
+ try {
330
+ await this._waitForData();
331
+ } catch (error) {
332
+ if (this._useDirectUrl && (error.message.includes('timed out') || error.message.includes('403'))) {
333
+ const cacheKey = `${videoId}:${this.config.ytdlp.format}`;
334
+ urlCache.invalidate(cacheKey);
335
+ log.warn('STREAM', `Invalidated cached URL for ${videoId} after error`);
336
+ }
337
+ this.destroy();
338
+ throw error;
339
+ }
240
340
 
241
341
  if (this.destroyed || !this.ffmpeg) {
242
342
  throw new Error('Stream destroyed during initialization');
@@ -250,31 +350,95 @@ class StreamController {
250
350
 
251
351
  const elapsed = Date.now() - startTimestamp;
252
352
  this.metrics.total = elapsed;
253
-
254
- log.info('STREAM', `Ready ${elapsed}ms | Metrics: [Metadata: ${this.metrics.metadata}ms | Spawn: ${this.metrics.spawn}ms | FirstByte: ${this.metrics.firstByte}ms]`);
353
+
354
+ log.info('STREAM', `Ready ${elapsed}ms | Metrics: [Metadata: ${this.metrics.metadata}ms | URLExtract: ${this.metrics.urlExtract}ms | Spawn: ${this.metrics.spawn}ms | FirstByte: ${this.metrics.firstByte}ms]`);
255
355
 
256
356
  return this.resource;
257
357
  }
258
358
 
259
- _waitForData(isLive = false) {
359
+ async create(seekPosition = 0) {
360
+ return this.start(seekPosition);
361
+ }
362
+
363
+ _extractUrl(videoId, config, isYouTube) {
364
+ return new Promise((resolve, reject) => {
365
+ const url = `https://www.youtube.com/watch?v=${videoId}`;
366
+ const args = [
367
+ '--get-url',
368
+ '-f', 'bestaudio/best',
369
+ '--no-playlist',
370
+ '--no-warnings',
371
+ url
372
+ ];
373
+
374
+ if (isYouTube) {
375
+ args.push('--extractor-args', 'youtube:player_client=web_creator');
376
+ }
377
+
378
+ if (config.cookiesPath) {
379
+ args.unshift('--cookies', config.cookiesPath);
380
+ }
381
+
382
+ const env = { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH };
383
+ const proc = spawn(config.ytdlpPath, args, { env });
384
+
385
+ let stdout = '';
386
+ let stderr = '';
387
+ const extractTimeout = config.stream?.extractTimeout || 30000;
388
+ const timeout = setTimeout(() => {
389
+ proc.kill('SIGKILL');
390
+ reject(new Error('URL extraction timed out'));
391
+ }, extractTimeout);
392
+
393
+ proc.stdout.on('data', (data) => { stdout += data; });
394
+ proc.stderr.on('data', (data) => { stderr += data; });
395
+
396
+ proc.on('close', (code) => {
397
+ clearTimeout(timeout);
398
+ if (code !== 0) {
399
+ return reject(new Error(`yt-dlp --get-url failed (code ${code}): ${stderr.slice(-200)}`));
400
+ }
401
+ const urls = stdout.trim().split('\n').filter(Boolean);
402
+ if (urls.length === 0) {
403
+ return reject(new Error('yt-dlp returned no URLs'));
404
+ }
405
+ resolve(urls[0]);
406
+ });
407
+
408
+ proc.on('error', (err) => {
409
+ clearTimeout(timeout);
410
+ reject(new Error(`yt-dlp spawn failed: ${err.message}`));
411
+ });
412
+ });
413
+ }
414
+
415
+ _waitForData() {
260
416
  const ffmpeg = this.ffmpeg;
261
417
  const ytdlp = this.ytdlp;
418
+ const isLive = this.track.isLive === true || this.track.duration === 0;
419
+ const isPipe = !!this.ytdlp;
420
+ const timeoutMs = isLive
421
+ ? (this.config.stream?.liveDataTimeout || 15000)
422
+ : isPipe
423
+ ? (this.config.stream?.pipeDataTimeout || 20000)
424
+ : (this.config.stream?.dataTimeout || 8000);
262
425
 
263
426
  return new Promise((resolve, reject) => {
264
427
  if (!ffmpeg) return resolve();
265
428
 
266
- const timeoutMs = isLive ? 30000 : 15000;
429
+ let resolved = false;
430
+
267
431
  const timeout = setTimeout(() => {
268
- log.warn('STREAM', `Timeout waiting for data, proceeding anyway`);
269
- resolve();
432
+ if (resolved) return;
433
+ resolved = true;
434
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
435
+ reject(new Error(`Stream timed out after ${timeoutMs}ms waiting for audio data`));
270
436
  }, timeoutMs);
271
437
 
272
- let resolved = false;
273
-
274
438
  const onReadable = () => {
275
439
  if (resolved) return;
276
440
  resolved = true;
277
- this.metrics.firstByte = Date.now() - (this.startTime + this.metrics.metadata + this.metrics.spawn);
441
+ this.metrics.firstByte = Date.now() - this._ffmpegSpawnTime;
278
442
  clearTimeout(timeout);
279
443
  if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
280
444
  resolve();
@@ -284,19 +448,20 @@ class StreamController {
284
448
  ffmpeg.stdout.on('readable', onReadable);
285
449
  }
286
450
 
287
- ffmpeg.on('close', () => {
288
- if (!resolved) {
289
- resolved = true;
290
- clearTimeout(timeout);
291
- if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
292
-
293
- if (this.destroyed) {
294
- return reject(new Error('Stream destroyed during initialization'));
295
- }
451
+ ffmpeg.on('close', (code) => {
452
+ if (resolved) return;
453
+ resolved = true;
454
+ clearTimeout(timeout);
455
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
296
456
 
297
- const sourceErr = ytdlp ? `yt-dlp stderr: ${this.ytdlpError.slice(-200) || 'none'}` : `ffmpeg stderr: ${this.ffmpegError.slice(-200) || 'none'}`;
298
- reject(new Error(`ffmpeg closed before producing data. ${sourceErr}`));
457
+ if (this.destroyed) {
458
+ return reject(new Error('Stream destroyed during initialization'));
299
459
  }
460
+
461
+ const sourceErr = ytdlp
462
+ ? `yt-dlp stderr: ${this.ytdlpError.slice(-200) || 'none'}`
463
+ : `ffmpeg stderr: ${this.ffmpegError.slice(-200) || 'none'}`;
464
+ reject(new Error(`ffmpeg closed before producing data. ${sourceErr}`));
300
465
  });
301
466
 
302
467
  if (ytdlp) {
@@ -305,12 +470,10 @@ class StreamController {
305
470
  resolved = true;
306
471
  clearTimeout(timeout);
307
472
  if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
308
-
309
473
  if (this.destroyed) {
310
474
  return reject(new Error('Stream destroyed during initialization'));
311
475
  }
312
-
313
- reject(new Error(`yt-dlp failed with code ${code}`));
476
+ reject(new Error(`yt-dlp failed with code ${code}: ${this.ytdlpError.slice(-200)}`));
314
477
  }
315
478
  });
316
479
  }
@@ -355,4 +518,4 @@ function createStream(track, filters, config) {
355
518
  return new StreamController(track, filters, config);
356
519
  }
357
520
 
358
- module.exports = { createStream, StreamController };
521
+ module.exports = { createStream, StreamController, urlCache };