streamify-audio 2.3.0 → 2.3.2
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 +9 -0
- package/package.json +1 -1
- package/src/config.js +11 -0
- package/src/discord/Manager.js +1 -1
- package/src/discord/Stream.js +9 -12
- package/src/providers/spotify.js +2 -2
- package/docs/README.md +0 -31
- package/docs/automation.md +0 -186
- package/docs/configuration.md +0 -198
- package/docs/discord/events.md +0 -206
- package/docs/discord/manager.md +0 -195
- package/docs/discord/player.md +0 -263
- package/docs/discord/queue.md +0 -197
- package/docs/examples/advanced-bot.md +0 -391
- package/docs/examples/basic-bot.md +0 -182
- package/docs/examples/lavalink.md +0 -156
- package/docs/filters.md +0 -347
- package/docs/http/endpoints.md +0 -224
- package/docs/http/server.md +0 -174
- package/docs/plans/2026-02-22-stream-revamp-design.md +0 -88
- package/docs/plans/2026-02-22-stream-revamp-plan.md +0 -814
- package/docs/quick-start.md +0 -92
- package/docs/sources.md +0 -189
- package/docs/sponsorblock.md +0 -95
- package/tests/cache.test.js +0 -234
- package/tests/config.test.js +0 -44
- package/tests/error-handling.test.js +0 -318
- package/tests/ffmpeg.test.js +0 -66
- package/tests/filters-edge.test.js +0 -333
- package/tests/http.test.js +0 -24
- package/tests/integration.test.js +0 -325
- package/tests/local.test.js +0 -37
- package/tests/queue.test.js +0 -94
- package/tests/spotify.test.js +0 -238
- package/tests/stream.test.js +0 -217
- package/tests/twitch.test.js +0 -42
- package/tests/utils.test.js +0 -60
- package/tests/youtube.test.js +0 -219
- package/youtube-cookies.txt +0 -26
|
@@ -1,814 +0,0 @@
|
|
|
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
|
-
```
|