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.
|
|
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
|
|
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
98
|
videoId = await spotify.resolveToYouTube(this.track.id, this.config);
|
|
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;
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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',
|
|
@@ -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 (
|
|
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,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
|
-
|
|
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
|
-
|
|
432
|
+
let resolved = false;
|
|
433
|
+
|
|
267
434
|
const timeout = setTimeout(() => {
|
|
268
|
-
|
|
269
|
-
|
|
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() -
|
|
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 (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
298
|
-
reject(new Error(
|
|
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 };
|
package/youtube-cookies.txt
CHANGED
|
@@ -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
|
|
20
|
-
.youtube.com TRUE / TRUE
|
|
21
|
-
.youtube.com TRUE / TRUE
|
|
22
|
-
.youtube.com TRUE / TRUE
|
|
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
|
|
25
|
-
.youtube.com TRUE / TRUE
|
|
26
|
-
.youtube.com TRUE / TRUE
|
|
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
|