streamify-audio 2.2.13 → 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.
@@ -0,0 +1,814 @@
1
+ # Stream System Revamp Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Rewrite the stream system for near-instant playback, proper error handling, URL caching, and aggressive prefetching.
6
+
7
+ **Architecture:** Two-phase StreamController (prepare + start) with a module-level URL cache. Player does full prefetch (spawn ffmpeg for next track) while current track plays. Timeout rejects instead of resolving silently.
8
+
9
+ **Key insight:** The old 15s timeout wasn't a "no data" problem — music *did* start playing, but only after ~15s because yt-dlp's format negotiation + piping is slow. The two-phase approach (`--get-url` then direct ffmpeg input) eliminates this bottleneck. The 8s timeout becomes a genuine safety net for truly broken streams, not the normal playback path.
10
+
11
+ **Tech Stack:** Node.js, yt-dlp (child_process), ffmpeg (child_process), @discordjs/voice
12
+
13
+ **Design doc:** `docs/plans/2026-02-22-stream-revamp-design.md`
14
+
15
+ ---
16
+
17
+ ### Task 1: Add stream config to config.js
18
+
19
+ **Files:**
20
+ - Modify: `src/config.js:5-41` (defaults object)
21
+
22
+ **Step 1: Add the `stream` section to the defaults object**
23
+
24
+ In `src/config.js`, add `stream` to the `defaults` object after `cache`:
25
+
26
+ ```js
27
+ stream: {
28
+ dataTimeout: 8000,
29
+ liveDataTimeout: 15000,
30
+ urlCacheTTL: 1800,
31
+ bufferSize: '1M',
32
+ maxRetries: 2
33
+ }
34
+ ```
35
+
36
+ **Step 2: Add stream config merging in load()**
37
+
38
+ In the `load()` function's config merge block (around line 82-111), add stream merging alongside the other nested configs:
39
+
40
+ ```js
41
+ stream: {
42
+ ...defaults.stream,
43
+ ...fileConfig.stream,
44
+ ...options.stream
45
+ }
46
+ ```
47
+
48
+ **Step 3: Verify manually**
49
+
50
+ Run: `node -e "const c = require('./src/config'); const cfg = c.load(); console.log(cfg.stream);"`
51
+ Expected: Prints the stream config object with all defaults.
52
+
53
+ **Step 4: Commit**
54
+
55
+ ```bash
56
+ git add src/config.js
57
+ git commit -m "feat: add stream config section (timeout, cache TTL, buffer size, retries)"
58
+ ```
59
+
60
+ ---
61
+
62
+ ### Task 2: Rewrite Stream.js — StreamURLCache
63
+
64
+ **Files:**
65
+ - Modify: `src/discord/Stream.js` (top of file, before StreamController class)
66
+
67
+ **Step 1: Add StreamURLCache at the top of Stream.js**
68
+
69
+ After the existing imports and `voiceModule` block, add:
70
+
71
+ ```js
72
+ class StreamURLCache {
73
+ constructor(defaultTTL = 1800000) {
74
+ this._cache = new Map();
75
+ this._defaultTTL = defaultTTL;
76
+ this._cleanupInterval = setInterval(() => this._cleanup(), 300000);
77
+ if (this._cleanupInterval.unref) this._cleanupInterval.unref();
78
+ }
79
+
80
+ get(key) {
81
+ const entry = this._cache.get(key);
82
+ if (!entry) return null;
83
+ if (Date.now() > entry.expiry) {
84
+ this._cache.delete(key);
85
+ return null;
86
+ }
87
+ return { url: entry.url, headers: entry.headers };
88
+ }
89
+
90
+ set(key, url, headers = null, ttl = this._defaultTTL) {
91
+ this._cache.set(key, {
92
+ url,
93
+ headers,
94
+ expiry: Date.now() + ttl
95
+ });
96
+ }
97
+
98
+ invalidate(key) {
99
+ this._cache.delete(key);
100
+ }
101
+
102
+ get size() {
103
+ return this._cache.size;
104
+ }
105
+
106
+ _cleanup() {
107
+ const now = Date.now();
108
+ for (const [key, entry] of this._cache) {
109
+ if (now > entry.expiry) this._cache.delete(key);
110
+ }
111
+ }
112
+ }
113
+
114
+ const urlCache = new StreamURLCache();
115
+ ```
116
+
117
+ **Step 2: Verify it loads**
118
+
119
+ Run: `node -e "require('./src/discord/Stream'); console.log('OK');"`
120
+ Expected: Prints "OK" without errors.
121
+
122
+ **Step 3: Commit**
123
+
124
+ ```bash
125
+ git add src/discord/Stream.js
126
+ git commit -m "feat: add StreamURLCache with TTL and auto-cleanup"
127
+ ```
128
+
129
+ ---
130
+
131
+ ### Task 3: Rewrite Stream.js — StreamController.prepare()
132
+
133
+ **Files:**
134
+ - Modify: `src/discord/Stream.js` (StreamController class)
135
+
136
+ **Step 1: Add `_extractUrl()` private method**
137
+
138
+ Add this method to StreamController. It spawns `yt-dlp --get-url` to extract a direct stream URL without downloading:
139
+
140
+ ```js
141
+ _extractUrl(videoId, config, isYouTube) {
142
+ return new Promise((resolve, reject) => {
143
+ const url = `https://www.youtube.com/watch?v=${videoId}`;
144
+ const args = [
145
+ '--get-url',
146
+ '-f', config.ytdlp.format,
147
+ '--no-playlist',
148
+ '--no-check-certificates',
149
+ '--no-warnings',
150
+ '--no-cache-dir',
151
+ url
152
+ ];
153
+
154
+ if (isYouTube) {
155
+ args.push('--extractor-args', 'youtube:player_client=web_creator');
156
+ }
157
+
158
+ if (config.cookiesPath) {
159
+ args.unshift('--cookies', config.cookiesPath);
160
+ }
161
+
162
+ if (config.sponsorblock?.enabled !== false && isYouTube) {
163
+ const categories = config.sponsorblock?.categories || ['sponsor', 'selfpromo'];
164
+ args.push('--sponsorblock-remove', categories.join(','));
165
+ }
166
+
167
+ const env = { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH };
168
+ const proc = spawn(config.ytdlpPath, args, { env });
169
+
170
+ let stdout = '';
171
+ let stderr = '';
172
+ const timeout = setTimeout(() => {
173
+ proc.kill('SIGKILL');
174
+ reject(new Error('URL extraction timed out'));
175
+ }, 15000);
176
+
177
+ proc.stdout.on('data', (data) => { stdout += data; });
178
+ proc.stderr.on('data', (data) => { stderr += data; });
179
+
180
+ proc.on('close', (code) => {
181
+ clearTimeout(timeout);
182
+ if (code !== 0) {
183
+ return reject(new Error(`yt-dlp --get-url failed (code ${code}): ${stderr.slice(-200)}`));
184
+ }
185
+ const urls = stdout.trim().split('\n').filter(Boolean);
186
+ if (urls.length === 0) {
187
+ return reject(new Error('yt-dlp returned no URLs'));
188
+ }
189
+ resolve(urls[0]);
190
+ });
191
+
192
+ proc.on('error', (err) => {
193
+ clearTimeout(timeout);
194
+ reject(new Error(`yt-dlp spawn failed: ${err.message}`));
195
+ });
196
+ });
197
+ }
198
+ ```
199
+
200
+ **Step 2: Add `prepare()` method**
201
+
202
+ This is the first phase — resolves Spotify, extracts/caches the direct URL:
203
+
204
+ ```js
205
+ async prepare() {
206
+ if (this.destroyed) throw new Error('Stream already destroyed');
207
+
208
+ this.startTime = Date.now();
209
+ const source = this.track.source || 'youtube';
210
+ let videoId = this.track._resolvedId || this.track.id;
211
+
212
+ if (source === 'spotify' && !this.track._resolvedId) {
213
+ log.info('STREAM', `Resolving Spotify track to YouTube: ${this.track.title}`);
214
+ try {
215
+ const spotify = require('../providers/spotify');
216
+ videoId = await spotify.resolveToYouTube(this.track.id, this.config);
217
+ this.track._resolvedId = videoId;
218
+ this.metrics.metadata = Date.now() - this.startTime;
219
+ } catch (error) {
220
+ throw new Error(`Failed to resolve Spotify track: ${error.message}`);
221
+ }
222
+ } else {
223
+ this.metrics.metadata = 0;
224
+ }
225
+
226
+ if (!videoId || videoId === 'undefined') {
227
+ throw new Error(`Invalid track ID: ${videoId} (source: ${source})`);
228
+ }
229
+
230
+ this._videoId = videoId;
231
+ const isYouTube = source === 'youtube' || source === 'spotify';
232
+ const isLive = this.track.isLive === true || this.track.duration === 0;
233
+ const isLocal = source === 'local';
234
+
235
+ if (isLocal || isLive || !isYouTube) {
236
+ this._useDirectUrl = false;
237
+ this._prepared = true;
238
+ return;
239
+ }
240
+
241
+ const cacheKey = `${videoId}:${this.config.ytdlp.format}`;
242
+ const cached = urlCache.get(cacheKey);
243
+
244
+ if (cached) {
245
+ log.info('STREAM', `URL cache hit for ${videoId}`);
246
+ this._directStreamUrl = cached.url;
247
+ this._directStreamHeaders = cached.headers;
248
+ this._useDirectUrl = true;
249
+ this._prepared = true;
250
+ return;
251
+ }
252
+
253
+ const extractStart = Date.now();
254
+ const maxRetries = this.config.stream?.maxRetries || 2;
255
+
256
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
257
+ try {
258
+ const extractedUrl = await this._extractUrl(videoId, this.config, isYouTube);
259
+ this.metrics.urlExtract = Date.now() - extractStart;
260
+
261
+ const ttl = (this.config.stream?.urlCacheTTL || 1800) * 1000;
262
+ urlCache.set(cacheKey, extractedUrl, this.track._headers, ttl);
263
+
264
+ this._directStreamUrl = extractedUrl;
265
+ this._directStreamHeaders = this.track._headers;
266
+ this._useDirectUrl = true;
267
+ this._prepared = true;
268
+
269
+ log.info('STREAM', `URL extracted for ${videoId} (${this.metrics.urlExtract}ms, attempt ${attempt})`);
270
+ return;
271
+ } catch (error) {
272
+ log.warn('STREAM', `URL extraction attempt ${attempt}/${maxRetries} failed: ${error.message}`);
273
+ if (attempt === maxRetries) {
274
+ log.warn('STREAM', `All extraction attempts failed for ${videoId}, falling back to pipe mode`);
275
+ this._useDirectUrl = false;
276
+ this._prepared = true;
277
+ return;
278
+ }
279
+ }
280
+ }
281
+ }
282
+ ```
283
+
284
+ **Step 3: Commit**
285
+
286
+ ```bash
287
+ git add src/discord/Stream.js
288
+ git commit -m "feat: add prepare() phase with URL extraction and caching"
289
+ ```
290
+
291
+ ---
292
+
293
+ ### Task 4: Rewrite Stream.js — StreamController.start()
294
+
295
+ **Files:**
296
+ - Modify: `src/discord/Stream.js` (StreamController class)
297
+
298
+ **Step 1: Rewrite `_waitForData()` to reject on timeout**
299
+
300
+ Replace the existing `_waitForData` method entirely:
301
+
302
+ ```js
303
+ _waitForData() {
304
+ const ffmpeg = this.ffmpeg;
305
+ const ytdlp = this.ytdlp;
306
+ const isLive = this.track.isLive === true || this.track.duration === 0;
307
+ const timeoutMs = isLive
308
+ ? (this.config.stream?.liveDataTimeout || 15000)
309
+ : (this.config.stream?.dataTimeout || 8000);
310
+
311
+ return new Promise((resolve, reject) => {
312
+ if (!ffmpeg) return resolve();
313
+
314
+ let resolved = false;
315
+
316
+ const timeout = setTimeout(() => {
317
+ if (resolved) return;
318
+ resolved = true;
319
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
320
+ reject(new Error(`Stream timed out after ${timeoutMs}ms waiting for audio data`));
321
+ }, timeoutMs);
322
+
323
+ const onReadable = () => {
324
+ if (resolved) return;
325
+ resolved = true;
326
+ this.metrics.firstByte = Date.now() - this._ffmpegSpawnTime;
327
+ clearTimeout(timeout);
328
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
329
+ resolve();
330
+ };
331
+
332
+ if (ffmpeg.stdout) {
333
+ ffmpeg.stdout.on('readable', onReadable);
334
+ }
335
+
336
+ ffmpeg.on('close', (code) => {
337
+ if (resolved) return;
338
+ resolved = true;
339
+ clearTimeout(timeout);
340
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
341
+
342
+ if (this.destroyed) {
343
+ return reject(new Error('Stream destroyed during initialization'));
344
+ }
345
+
346
+ const sourceErr = ytdlp
347
+ ? `yt-dlp stderr: ${this.ytdlpError.slice(-200) || 'none'}`
348
+ : `ffmpeg stderr: ${this.ffmpegError.slice(-200) || 'none'}`;
349
+ reject(new Error(`ffmpeg closed before producing data. ${sourceErr}`));
350
+ });
351
+
352
+ if (ytdlp) {
353
+ ytdlp.on('close', (code) => {
354
+ if (!resolved && code !== 0 && code !== null) {
355
+ resolved = true;
356
+ clearTimeout(timeout);
357
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
358
+ if (this.destroyed) {
359
+ return reject(new Error('Stream destroyed during initialization'));
360
+ }
361
+ reject(new Error(`yt-dlp failed with code ${code}: ${this.ytdlpError.slice(-200)}`));
362
+ }
363
+ });
364
+ }
365
+ });
366
+ }
367
+ ```
368
+
369
+ **Step 2: Rewrite `create()` → `start()` method**
370
+
371
+ Replace the existing `create()` method. The new `start()` uses the URL from `prepare()` when available, falls back to yt-dlp pipe for live/unsupported:
372
+
373
+ ```js
374
+ async start(seekPosition = 0) {
375
+ if (this.destroyed) throw new Error('Stream already destroyed');
376
+ if (this.resource) return this.resource;
377
+
378
+ if (!this._prepared) {
379
+ await this.prepare();
380
+ }
381
+
382
+ if (!this.startTime) this.startTime = Date.now();
383
+ const startTimestamp = this.startTime;
384
+
385
+ const videoId = this._videoId || this.track._resolvedId || this.track.id;
386
+ const source = this.track.source || 'youtube';
387
+ const isLive = this.track.isLive === true || this.track.duration === 0;
388
+ const isLocal = source === 'local';
389
+ const isYouTube = source === 'youtube' || source === 'spotify';
390
+
391
+ log.info('STREAM', `Creating stream for ${videoId} (${source}, mode: ${this._useDirectUrl ? 'direct' : isLocal ? 'local' : 'pipe'})`);
392
+
393
+ const filterNames = Object.keys(this.filters).filter(k => k !== 'start' && k !== '_trigger' && k !== 'volume');
394
+ if (filterNames.length > 0) {
395
+ const chain = filterNames.map(name => {
396
+ const val = this.filters[name];
397
+ let displayVal = typeof val === 'object' ? JSON.stringify(val) : val;
398
+ if (displayVal === true || displayVal === 'true') displayVal = 'ON';
399
+ return `[${name.toUpperCase()} (${displayVal})]`;
400
+ }).join(' > ');
401
+ log.info('STREAM', `Filter Chain: ${chain}`);
402
+ }
403
+
404
+ const env = { ...process.env, PATH: '/usr/local/bin:/root/.deno/bin:' + process.env.PATH };
405
+ const bufferSize = this.config.stream?.bufferSize || '1M';
406
+
407
+ if (!this._useDirectUrl && !isLocal) {
408
+ let url;
409
+ if (source === 'soundcloud') {
410
+ url = this.track.uri || `https://api.soundcloud.com/tracks/${videoId}/stream`;
411
+ } else if (['twitch', 'mixcloud', 'bandcamp', 'http'].includes(source)) {
412
+ url = this.track.uri || videoId;
413
+ } else {
414
+ url = `https://www.youtube.com/watch?v=${videoId}`;
415
+ }
416
+
417
+ const formatString = isLive ? 'bestaudio*/best' : this.config.ytdlp.format;
418
+ const ytdlpArgs = [
419
+ '-f', formatString,
420
+ '--no-playlist',
421
+ '--no-check-certificates',
422
+ '--no-warnings',
423
+ '--no-cache-dir',
424
+ '--no-mtime',
425
+ '--buffer-size', bufferSize,
426
+ '--quiet',
427
+ '--retries', '3',
428
+ '--fragment-retries', '3',
429
+ '-o', '-',
430
+ ...this.config.ytdlp.additionalArgs,
431
+ url
432
+ ];
433
+
434
+ if (isLive) {
435
+ ytdlpArgs.push('--no-live-from-start');
436
+ } else if (isYouTube) {
437
+ ytdlpArgs.push('--extractor-args', 'youtube:player_client=web_creator');
438
+ }
439
+
440
+ if (seekPosition > 0) {
441
+ const seekSeconds = Math.floor(seekPosition / 1000);
442
+ ytdlpArgs.push('--download-sections', `*${seekSeconds}-`);
443
+ log.info('STREAM', `Seeking to ${seekSeconds}s`);
444
+ }
445
+
446
+ if (this.config.cookiesPath) {
447
+ ytdlpArgs.unshift('--cookies', this.config.cookiesPath);
448
+ }
449
+
450
+ if (this.config.sponsorblock?.enabled !== false && isYouTube) {
451
+ const categories = this.config.sponsorblock?.categories || ['sponsor', 'selfpromo'];
452
+ ytdlpArgs.push('--sponsorblock-remove', categories.join(','));
453
+ }
454
+
455
+ this.ytdlp = spawn(this.config.ytdlpPath, ytdlpArgs, { env });
456
+ }
457
+
458
+ const ffmpegFilters = { ...this.filters };
459
+ const ffmpegArgs = buildFfmpegArgs(ffmpegFilters, this.config);
460
+
461
+ if (isLocal) {
462
+ const filePath = this.track.absolutePath || videoId.replace('file://', '');
463
+ const pipeIndex = ffmpegArgs.indexOf('pipe:0');
464
+ if (pipeIndex > 0) {
465
+ ffmpegArgs[pipeIndex] = filePath;
466
+ if (seekPosition > 0) {
467
+ const seekSeconds = (seekPosition / 1000).toFixed(3);
468
+ ffmpegArgs.splice(pipeIndex - 1, 0, '-ss', seekSeconds);
469
+ }
470
+ }
471
+ } else if (this._useDirectUrl) {
472
+ let inputUrl = this._directStreamUrl;
473
+
474
+ if (seekPosition > 0) {
475
+ const pipeIndex = ffmpegArgs.indexOf('pipe:0');
476
+ if (pipeIndex > 0) {
477
+ const seekSeconds = (seekPosition / 1000).toFixed(3);
478
+ ffmpegArgs.splice(pipeIndex - 1, 0, '-ss', seekSeconds);
479
+ }
480
+ }
481
+
482
+ const pipeIndex = ffmpegArgs.indexOf('pipe:0');
483
+ if (pipeIndex > 0) {
484
+ ffmpegArgs[pipeIndex] = inputUrl;
485
+ if (this._directStreamHeaders) {
486
+ const headers = Object.entries(this._directStreamHeaders)
487
+ .map(([k, v]) => `${k}: ${v}`)
488
+ .join('\r\n');
489
+ ffmpegArgs.splice(pipeIndex - 1, 0, '-headers', headers);
490
+ }
491
+ }
492
+ }
493
+
494
+ this._ffmpegSpawnTime = Date.now();
495
+ this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs, { env });
496
+ this.metrics.spawn = Date.now() - startTimestamp - this.metrics.metadata;
497
+
498
+ if (this.ytdlp) {
499
+ this.ffmpeg.stdin.on('error', (err) => {
500
+ if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
501
+ log.error('STREAM', `ffmpeg stdin error: ${err.message}`);
502
+ }
503
+ });
504
+
505
+ this.ytdlp.stdout.on('error', (err) => {
506
+ if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
507
+ log.error('STREAM', `yt-dlp stdout error: ${err.message}`);
508
+ }
509
+ });
510
+
511
+ this.ytdlp.stdout.pipe(this.ffmpeg.stdin);
512
+
513
+ this.ytdlp.stderr.on('data', (data) => {
514
+ if (this.destroyed) return;
515
+ const msg = data.toString();
516
+ this.ytdlpError += msg;
517
+ if (msg.includes('ERROR:') && !msg.includes('Retrying') && !msg.includes('Broken pipe')) {
518
+ log.error('YTDLP', msg.trim());
519
+ }
520
+ });
521
+
522
+ this.ytdlp.on('close', (code) => {
523
+ if (code !== 0 && code !== null && !this.destroyed) {
524
+ log.error('STREAM', `yt-dlp failed (code: ${code}) for ${videoId}`);
525
+ }
526
+ if (this.ffmpeg && !this.ffmpeg.killed && this.ffmpeg.stdin) {
527
+ try { this.ffmpeg.stdin.end(); } catch(e) {}
528
+ }
529
+ });
530
+ }
531
+
532
+ this.ffmpeg.stderr.on('data', (data) => {
533
+ if (this.destroyed) return;
534
+ const msg = data.toString();
535
+ this.ffmpegError += msg;
536
+ if ((msg.includes('Error') || msg.includes('error')) && !msg.includes('Connection reset') && !msg.includes('Broken pipe')) {
537
+ log.error('FFMPEG', msg.trim());
538
+ }
539
+ });
540
+
541
+ try {
542
+ await this._waitForData();
543
+ } catch (error) {
544
+ if (this._useDirectUrl && error.message.includes('timed out')) {
545
+ const cacheKey = `${videoId}:${this.config.ytdlp.format}`;
546
+ urlCache.invalidate(cacheKey);
547
+ log.warn('STREAM', `Invalidated cached URL for ${videoId} after timeout`);
548
+ }
549
+ this.destroy();
550
+ throw error;
551
+ }
552
+
553
+ if (this.destroyed || !this.ffmpeg) {
554
+ throw new Error('Stream destroyed during initialization');
555
+ }
556
+
557
+ this.resource = createAudioResource(this.ffmpeg.stdout, {
558
+ inputType: StreamType.OggOpus,
559
+ inlineVolume: false,
560
+ silencePaddingFrames: 5
561
+ });
562
+
563
+ const elapsed = Date.now() - startTimestamp;
564
+ this.metrics.total = elapsed;
565
+
566
+ log.info('STREAM', `Ready ${elapsed}ms | Metrics: [Metadata: ${this.metrics.metadata}ms | URLExtract: ${this.metrics.urlExtract || 0}ms | Spawn: ${this.metrics.spawn}ms | FirstByte: ${this.metrics.firstByte || 0}ms]`);
567
+
568
+ return this.resource;
569
+ }
570
+ ```
571
+
572
+ **Step 3: Keep `create()` as an alias for backward compat**
573
+
574
+ Add a simple alias so Player.js and any other callers still work:
575
+
576
+ ```js
577
+ async create(seekPosition = 0) {
578
+ return this.start(seekPosition);
579
+ }
580
+ ```
581
+
582
+ **Step 4: Update constructor to initialize new fields**
583
+
584
+ Add these to the constructor after existing fields:
585
+
586
+ ```js
587
+ this._videoId = null;
588
+ this._directStreamUrl = null;
589
+ this._directStreamHeaders = null;
590
+ this._useDirectUrl = false;
591
+ this._prepared = false;
592
+ this._ffmpegSpawnTime = null;
593
+ this.metrics.urlExtract = 0;
594
+ ```
595
+
596
+ **Step 5: Commit**
597
+
598
+ ```bash
599
+ git add src/discord/Stream.js
600
+ git commit -m "feat: rewrite start() with two-phase pipeline and proper timeout rejection"
601
+ ```
602
+
603
+ ---
604
+
605
+ ### Task 5: Rewrite Player.js — Aggressive Prefetch
606
+
607
+ **Files:**
608
+ - Modify: `src/discord/Player.js:383-405` (`_prefetchNext` method)
609
+ - Modify: `src/discord/Player.js:313-373` (`_playTrack` method)
610
+
611
+ **Step 1: Rewrite `_prefetchNext()` to do full stream preparation**
612
+
613
+ Replace the existing `_prefetchNext` method:
614
+
615
+ ```js
616
+ async _prefetchNext() {
617
+ if (this._prefetching || this.queue.tracks.length === 0) return;
618
+
619
+ const nextTrack = this.queue.tracks[0];
620
+ if (!nextTrack) return;
621
+
622
+ if (this._prefetchedTrack?.id === nextTrack.id && this._prefetchedStream) return;
623
+
624
+ this._clearPrefetch();
625
+ this._prefetching = true;
626
+ log.debug('PLAYER', `Prefetching stream: ${nextTrack.title}`);
627
+
628
+ try {
629
+ const filtersWithVolume = { ...this._filters, volume: this._volume };
630
+ const stream = createStream(nextTrack, filtersWithVolume, this.config);
631
+
632
+ await stream.prepare();
633
+
634
+ if (this._destroyed || this.queue.tracks[0]?.id !== nextTrack.id) {
635
+ stream.destroy();
636
+ return;
637
+ }
638
+
639
+ await stream.start(0);
640
+
641
+ if (this._destroyed || this.queue.tracks[0]?.id !== nextTrack.id) {
642
+ stream.destroy();
643
+ return;
644
+ }
645
+
646
+ this._prefetchedStream = stream;
647
+ this._prefetchedTrack = nextTrack;
648
+ log.info('PLAYER', `Prefetch ready: ${nextTrack.title} (${stream.metrics.total}ms)`);
649
+ } catch (error) {
650
+ log.debug('PLAYER', `Prefetch failed: ${error.message}`);
651
+ } finally {
652
+ this._prefetching = false;
653
+ }
654
+ }
655
+ ```
656
+
657
+ **Step 2: Update `_playTrack()` to use prefetched streams**
658
+
659
+ Replace the existing `_playTrack` method. Key changes:
660
+ - Checks prefetch match with resource validation
661
+ - Creates two-phase stream on miss
662
+ - Clears prefetch on filter/volume mismatch
663
+
664
+ ```js
665
+ async _playTrack(track, startPosition = 0) {
666
+ if (!track) return;
667
+
668
+ log.info('PLAYER', `Playing: ${track.title} (${track.id})` + (startPosition > 0 ? ` @ ${Math.floor(startPosition / 1000)}s` : ''));
669
+ this.emit('trackStart', track);
670
+
671
+ let newStream = null;
672
+ let resource = null;
673
+
674
+ try {
675
+ const filtersWithVolume = { ...this._filters, volume: this._volume };
676
+
677
+ if (startPosition === 0 && this._prefetchedTrack?.id === track.id && this._prefetchedStream?.resource) {
678
+ log.info('PLAYER', `Using prefetched stream for ${track.id}`);
679
+ newStream = this._prefetchedStream;
680
+ resource = newStream.resource;
681
+ this._prefetchedStream = null;
682
+ this._prefetchedTrack = null;
683
+ } else {
684
+ this._clearPrefetch();
685
+ newStream = createStream(track, filtersWithVolume, this.config);
686
+ resource = await newStream.start(startPosition);
687
+ }
688
+
689
+ const oldStream = this.stream;
690
+ this.stream = newStream;
691
+ this.audioPlayer.play(resource);
692
+
693
+ if (oldStream && oldStream !== newStream) {
694
+ oldStream.destroy();
695
+ }
696
+
697
+ this._playing = true;
698
+ this._paused = false;
699
+ this._positionMs = startPosition;
700
+ this._positionTimestamp = Date.now();
701
+
702
+ this._prefetchNext();
703
+ setImmediate(() => this._updateVoiceChannelStatus(track));
704
+
705
+ return track;
706
+ } catch (error) {
707
+ if (newStream && newStream !== this.stream) {
708
+ newStream.destroy();
709
+ }
710
+
711
+ if (this._manualSkip || this._changingStream || error.message.includes('Stream destroyed')) {
712
+ log.debug('PLAYER', `Playback cancelled for ${track.id}`);
713
+ return;
714
+ }
715
+
716
+ log.error('PLAYER', `Failed to play track: ${error.message}`);
717
+ this.emit('trackError', track, error);
718
+
719
+ const next = this.queue.shift();
720
+ if (next) {
721
+ setImmediate(() => this._playTrack(next));
722
+ } else {
723
+ this.emit('queueEnd');
724
+ }
725
+ }
726
+ }
727
+ ```
728
+
729
+ **Step 3: Clear prefetch on filter/volume changes**
730
+
731
+ In `setVolume()` (around line 653), add `this._clearPrefetch();` at the start of the method before the `if (this._playing ...)` check.
732
+
733
+ In `setFilter()` (around line 691), add `this._clearPrefetch();` at the start after setting the filter value.
734
+
735
+ In `setEffectPresets()` (around line 748), add `this._clearPrefetch();` after recalculating filters.
736
+
737
+ **Step 4: Commit**
738
+
739
+ ```bash
740
+ git add src/discord/Player.js
741
+ git commit -m "feat: aggressive prefetch with full stream pre-creation"
742
+ ```
743
+
744
+ ---
745
+
746
+ ### Task 6: Manual Integration Test
747
+
748
+ **Step 1: Start the service**
749
+
750
+ Run: `pm2 restart streamify`
751
+
752
+ **Step 2: Check logs for startup errors**
753
+
754
+ Run: `pm2 logs streamify --lines 20`
755
+ Expected: Clean startup, no errors.
756
+
757
+ **Step 3: Test playback via Discord bot**
758
+
759
+ Play a YouTube track. Verify in logs:
760
+ - `URL extracted for <id>` (not the old "Creating stream" without extraction)
761
+ - `Ready <time>ms` with timing breakdown including `URLExtract`
762
+ - Stream starts playing without the old 15s timeout warning
763
+
764
+ **Step 4: Test prefetch**
765
+
766
+ Queue two tracks. After first starts, verify in logs:
767
+ - `Prefetching stream: <next track title>`
768
+ - `Prefetch ready: <next track title> (<time>ms)`
769
+ - When first track ends/skips: `Using prefetched stream for <id>` — near-instant transition
770
+
771
+ **Step 5: Test URL cache**
772
+
773
+ Play the same track again (or seek). Verify:
774
+ - `URL cache hit for <id>` — skips yt-dlp extraction
775
+
776
+ **Step 6: Test timeout behavior**
777
+
778
+ This is harder to trigger naturally. But verify in code review that:
779
+ - `_waitForData()` rejects (not resolves) on timeout
780
+ - Player catches the error and auto-skips
781
+
782
+ **Step 7: Commit any fixes**
783
+
784
+ ```bash
785
+ git add -A
786
+ git commit -m "fix: post-integration test fixes"
787
+ ```
788
+
789
+ ---
790
+
791
+ ### Task 7: Cleanup & Final Commit
792
+
793
+ **Step 1: Remove dead code**
794
+
795
+ In Stream.js, verify no old methods remain unused. The old `create()` is now an alias for `start()`.
796
+
797
+ **Step 2: Verify all existing functionality works**
798
+
799
+ - Seek (creates new stream with position)
800
+ - Volume change (recreates stream)
801
+ - Filter change (recreates stream, clears prefetch)
802
+ - Pause/resume (stream destroy after 5min, recreate on resume)
803
+ - Skip (uses prefetch if available)
804
+ - Stop/destroy (cleans up prefetch)
805
+ - Local files (bypass URL extraction)
806
+ - SoundCloud/Twitch/etc (pipe mode fallback)
807
+ - Live streams (pipe mode, longer timeout)
808
+
809
+ **Step 3: Final commit**
810
+
811
+ ```bash
812
+ git add -A
813
+ git commit -m "chore: stream system revamp complete — two-phase pipeline, URL cache, aggressive prefetch"
814
+ ```