streamify-audio 2.2.14 → 2.3.0

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": "streamify-audio",
3
- "version": "2.2.14",
3
+ "version": "2.3.0",
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,13 @@ const defaults = {
38
38
  searchTTL: 300,
39
39
  infoTTL: 3600,
40
40
  redis: null
41
+ },
42
+ stream: {
43
+ dataTimeout: 8000,
44
+ liveDataTimeout: 15000,
45
+ urlCacheTTL: 1800,
46
+ bufferSize: '1M',
47
+ maxRetries: 2
41
48
  }
42
49
  };
43
50
 
@@ -107,6 +114,11 @@ function load(options = {}) {
107
114
  ...defaults.cache,
108
115
  ...fileConfig.cache,
109
116
  ...options.cache
117
+ },
118
+ stream: {
119
+ ...defaults.stream,
120
+ ...fileConfig.stream,
121
+ ...options.stream
110
122
  }
111
123
  };
112
124
 
@@ -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
98
  videoId = await spotify.resolveToYouTube(this.track.id, this.config);
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;
121
+ }
122
+
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();
69
171
  }
70
172
 
71
- log.info('STREAM', `Creating stream for ${videoId} (${source})`);
72
-
73
- // Log Filter Chain Trace (No data impact)
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',
@@ -156,13 +244,11 @@ class StreamController {
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,98 @@ 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', config.ytdlp.format,
369
+ '--no-playlist',
370
+ '--no-check-certificates',
371
+ '--no-warnings',
372
+ '--no-cache-dir',
373
+ url
374
+ ];
375
+
376
+ if (isYouTube) {
377
+ args.push('--extractor-args', 'youtube:player_client=web_creator');
378
+ }
379
+
380
+ if (config.cookiesPath) {
381
+ args.unshift('--cookies', config.cookiesPath);
382
+ }
383
+
384
+ if (config.sponsorblock?.enabled !== false && isYouTube) {
385
+ const categories = config.sponsorblock?.categories || ['sponsor', 'selfpromo'];
386
+ args.push('--sponsorblock-remove', categories.join(','));
387
+ }
388
+
389
+ const env = { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH };
390
+ const proc = spawn(config.ytdlpPath, args, { env });
391
+
392
+ let stdout = '';
393
+ let stderr = '';
394
+ const timeout = setTimeout(() => {
395
+ proc.kill('SIGKILL');
396
+ reject(new Error('URL extraction timed out'));
397
+ }, 15000);
398
+
399
+ proc.stdout.on('data', (data) => { stdout += data; });
400
+ proc.stderr.on('data', (data) => { stderr += data; });
401
+
402
+ proc.on('close', (code) => {
403
+ clearTimeout(timeout);
404
+ if (code !== 0) {
405
+ return reject(new Error(`yt-dlp --get-url failed (code ${code}): ${stderr.slice(-200)}`));
406
+ }
407
+ const urls = stdout.trim().split('\n').filter(Boolean);
408
+ if (urls.length === 0) {
409
+ return reject(new Error('yt-dlp returned no URLs'));
410
+ }
411
+ resolve(urls[0]);
412
+ });
413
+
414
+ proc.on('error', (err) => {
415
+ clearTimeout(timeout);
416
+ reject(new Error(`yt-dlp spawn failed: ${err.message}`));
417
+ });
418
+ });
419
+ }
420
+
421
+ _waitForData() {
260
422
  const ffmpeg = this.ffmpeg;
261
423
  const ytdlp = this.ytdlp;
424
+ const isLive = this.track.isLive === true || this.track.duration === 0;
425
+ const timeoutMs = isLive
426
+ ? (this.config.stream?.liveDataTimeout || 15000)
427
+ : (this.config.stream?.dataTimeout || 8000);
262
428
 
263
429
  return new Promise((resolve, reject) => {
264
430
  if (!ffmpeg) return resolve();
265
431
 
266
- const timeoutMs = isLive ? 30000 : 15000;
432
+ let resolved = false;
433
+
267
434
  const timeout = setTimeout(() => {
268
- log.warn('STREAM', `Timeout waiting for data, proceeding anyway`);
269
- resolve();
435
+ if (resolved) return;
436
+ resolved = true;
437
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
438
+ reject(new Error(`Stream timed out after ${timeoutMs}ms waiting for audio data`));
270
439
  }, timeoutMs);
271
440
 
272
- let resolved = false;
273
-
274
441
  const onReadable = () => {
275
442
  if (resolved) return;
276
443
  resolved = true;
277
- this.metrics.firstByte = Date.now() - (this.startTime + this.metrics.metadata + this.metrics.spawn);
444
+ this.metrics.firstByte = Date.now() - this._ffmpegSpawnTime;
278
445
  clearTimeout(timeout);
279
446
  if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
280
447
  resolve();
@@ -284,19 +451,20 @@ class StreamController {
284
451
  ffmpeg.stdout.on('readable', onReadable);
285
452
  }
286
453
 
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
- }
454
+ ffmpeg.on('close', (code) => {
455
+ if (resolved) return;
456
+ resolved = true;
457
+ clearTimeout(timeout);
458
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
296
459
 
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}`));
460
+ if (this.destroyed) {
461
+ return reject(new Error('Stream destroyed during initialization'));
299
462
  }
463
+
464
+ const sourceErr = ytdlp
465
+ ? `yt-dlp stderr: ${this.ytdlpError.slice(-200) || 'none'}`
466
+ : `ffmpeg stderr: ${this.ffmpegError.slice(-200) || 'none'}`;
467
+ reject(new Error(`ffmpeg closed before producing data. ${sourceErr}`));
300
468
  });
301
469
 
302
470
  if (ytdlp) {
@@ -305,12 +473,10 @@ class StreamController {
305
473
  resolved = true;
306
474
  clearTimeout(timeout);
307
475
  if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
308
-
309
476
  if (this.destroyed) {
310
477
  return reject(new Error('Stream destroyed during initialization'));
311
478
  }
312
-
313
- reject(new Error(`yt-dlp failed with code ${code}`));
479
+ reject(new Error(`yt-dlp failed with code ${code}: ${this.ytdlpError.slice(-200)}`));
314
480
  }
315
481
  });
316
482
  }
@@ -355,4 +521,4 @@ function createStream(track, filters, config) {
355
521
  return new StreamController(track, filters, config);
356
522
  }
357
523
 
358
- module.exports = { createStream, StreamController };
524
+ module.exports = { createStream, StreamController, urlCache };
@@ -16,11 +16,11 @@
16
16
  .youtube.com TRUE / TRUE 1803850170 __Secure-1PSID g.a0006AhyolP4RoeyrQXcHsFnRGkuVLVIZ9QQsgpwFaXGd822GTtb9khH10B1YSjuMht-R8LNjAACgYKAQ8SARMSFQHGX2MiWX5D8_L5MAZK1TLabPQMghoVAUF8yKoFUiQLoi8zHLuskryhcx1P0076
17
17
  .youtube.com TRUE / TRUE 1803850170 __Secure-3PSID g.a0006AhyolP4RoeyrQXcHsFnRGkuVLVIZ9QQsgpwFaXGd822GTtbc5VtVrqvDy9fWzO4vguctgACgYKAUkSARMSFQHGX2MinKYgh3CtvTW5E5T8SwXiERoVAUF8yKqzIJTS4ajNX5uO8nh3r0j10076
18
18
  .youtube.com TRUE / TRUE 1803850170 LOGIN_INFO AFmmF2swRQIgH6IrdLYFOhW8xu4opIUHTGxAf33LzI5EXw6pdoqsh_gCIQCxYHQL4VZEyb3nE-rhXM6VU2ua99G4AhzKl4JJlzZBEQ:QUQ3MjNmeFpJOS1rVDgtM2I4cm50Z3M4NUplLTlSbXV4OFl4MTM5eUtHX3FEcVNmT2ltVFlSWmpQdlR2QTVqR29mZlUtTVh4OEhkOGw4Rkd5d2tQZm52NXJmQzdrMFJPVk5uYTFLNFFMZWlGelJhRVk1TkVaMm9Uby0xWlcwR3ozYkwwY2lWT21pV2dpU0lsWW5saFhMektISWxwUkRUcXl3
19
- .youtube.com TRUE / FALSE 1802574095 SIDCC AKEyXzXX7z54BpXowMgsJlE2WhHTn-XJfl-HESWVwvIuF-XZTI41oT6RE3J3Gml8YFOIT5nIFwk
20
- .youtube.com TRUE / TRUE 1802574095 __Secure-1PSIDCC AKEyXzVeAdBZZPpvwaPZA3WV5R_wg__e9qY7kWwe2K-V2jS627fDM29WKIr_3CezOeUVY52Isg
21
- .youtube.com TRUE / TRUE 1802574095 __Secure-3PSIDCC AKEyXzUMNwuEmFU-r8lgBnKD4qe6OP3Wi8eCSkJQrt-4WsUPToW1hs_sfuT-OqMNTQruKLpnfw
22
- .youtube.com TRUE / TRUE 1786573286 __Secure-YNID 15.YT=e2qw4ji33I446Rqr9VcM1oUeAeMMQtzK_4XR9QTmeKD0Fh5ONXvMNU0xEWTjLwPvQDQ8ObjZM8h7HSHQsS4Rle2Na0cTLhm7IYs0ZDhJs-pG-jFE9NSjq9bqlS_NihoFh3SP144sgCZpL8K_ZoLPZq0E5LPCl0X6cJ7GdXw1e3oY3AKiB_LIoaIaUyBOTazRokN4s_PtsLQrErQtnG7_ImvPSY5HDWb6qZCQujMU9pV0xDtXiHlJU4hzRyyN1GzbBy3mMzqpnqX53SU0gbofaLW55ep-iGeSySMDGmYTiLxc4tSr2Wy6XJ1-6AKpowymKn_Oam7j7vxK_rREysa_7g
19
+ .youtube.com TRUE / FALSE 1803305074 SIDCC AKEyXzW6XAqY9tNhDWlFzlv-TcDtmpxQxQjOpTwkeLq7ddP20Ecx5ttbRGa7K7WKJVXYBIsbGTE
20
+ .youtube.com TRUE / TRUE 1803305074 __Secure-1PSIDCC AKEyXzVdeiA5hPJOoWk_iVZXhBC3-3D4ZAlHjwkIdtnQ-y_iHxOeAdYucag2Dv-EMebAtINo-Q
21
+ .youtube.com TRUE / TRUE 1803305074 __Secure-3PSIDCC AKEyXzX9LV_1LP1Z1T6SCow5CKbPxteZg-XPIOt3o2Uu6OlsIn6Z2S3gRZBgUTHpaiRcVbPYbg
22
+ .youtube.com TRUE / TRUE 1787290794 __Secure-YNID 16.YT=uhIB3j65_j5F-nN_AIVu93J6J4tqe4fviGdBQuGk0X5EW7VRESfm4qMadhbgM04D7x1YyYRXKI0UIHJjiRMWqsP5PmT_qrHoTUSn4MOLA-FPeX09ELODYdpGvps9F6B3EFC2jC4MXYiWVY6KGZRiEoYBh_KWkh4Suwgn38LgfqN0E4Xv3YAzCB390qeKtLVkaqlQDUIvhnqGBPrObW-DzawPn00gHQph8VM2Zrklq_BzRYVaZHKHs2WkKlHqLljxwOiNXcsLrIkLqGsCgKR_onVOnlWN2gd0zojNv0VyuwYgfB9GBOLzweTnTWBZmp9IPWRA39DH4zMFerdTND-Yfg
23
23
  .youtube.com TRUE / TRUE 0 YSC -LHUdUOZvC4
24
- .youtube.com TRUE / TRUE 1786590095 VISITOR_INFO1_LIVE AuEVGsoJz2Y
25
- .youtube.com TRUE / TRUE 1786590095 VISITOR_PRIVACY_METADATA CgJVUxIEGgAgVQ%3D%3D
26
- .youtube.com TRUE / TRUE 1786573286 __Secure-ROLLOUT_TOKEN CIbdoKq8kOipXhC__cjij6WSAxjF-py1wNeSAw%3D%3D
24
+ .youtube.com TRUE / TRUE 1787321074 VISITOR_INFO1_LIVE AuEVGsoJz2Y
25
+ .youtube.com TRUE / TRUE 1787321074 VISITOR_PRIVACY_METADATA CgJVUxIEGgAgVQ%3D%3D
26
+ .youtube.com TRUE / TRUE 1787290794 __Secure-ROLLOUT_TOKEN CIbdoKq8kOipXhC__cjij6WSAxiZi8-rseySAw%3D%3D