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/docs/plans/2026-02-22-stream-revamp-design.md +88 -0
- package/docs/plans/2026-02-22-stream-revamp-plan.md +814 -0
- package/index.d.ts +9 -0
- package/package.json +1 -1
- package/src/config.js +23 -0
- package/src/discord/Manager.js +1 -1
- package/src/discord/Player.js +35 -15
- package/src/discord/Stream.js +250 -87
- package/src/providers/spotify.js +2 -2
- package/youtube-cookies.txt +7 -7
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.
|
|
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
|
|
package/src/discord/Manager.js
CHANGED
|
@@ -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
|
|
package/src/discord/Player.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
|
394
|
+
log.debug('PLAYER', `Prefetching stream: ${nextTrack.title}`);
|
|
391
395
|
|
|
392
396
|
try {
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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) {
|
package/src/discord/Stream.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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() -
|
|
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;
|
|
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}
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
-
|
|
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',
|
|
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
|
|
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 (
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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(
|
|
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() -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
+
let resolved = false;
|
|
430
|
+
|
|
267
431
|
const timeout = setTimeout(() => {
|
|
268
|
-
|
|
269
|
-
|
|
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() -
|
|
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 (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
298
|
-
reject(new Error(
|
|
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 };
|