streamify-audio 2.2.5 → 2.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamify-audio",
3
- "version": "2.2.5",
3
+ "version": "2.2.6",
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",
@@ -56,6 +56,7 @@ function cleanup() {
56
56
  }
57
57
  }
58
58
 
59
- setInterval(cleanup, 60000);
59
+ const cleanupInterval = setInterval(cleanup, 60000);
60
+ cleanupInterval.unref();
60
61
 
61
62
  module.exports = { get, set, del, clear, stats };
package/src/config.js CHANGED
@@ -68,6 +68,7 @@ function checkYtdlpVersion(ytdlpPath) {
68
68
  }
69
69
 
70
70
  function load(options = {}) {
71
+ options = options || {};
71
72
  let fileConfig = {};
72
73
 
73
74
  if (options.configPath && fs.existsSync(options.configPath)) {
@@ -382,27 +382,21 @@ class Player extends EventEmitter {
382
382
  if (this._prefetching || this.queue.tracks.length === 0) return;
383
383
 
384
384
  const nextTrack = this.queue.tracks[0];
385
- if (!nextTrack || this._prefetchedTrack?.id === nextTrack.id) return;
385
+ if (!nextTrack || nextTrack._directUrl) return; // Already prefetched or resolved
386
386
 
387
387
  this._prefetching = true;
388
- this._clearPrefetch();
389
-
390
- log.debug('PLAYER', `Prefetching: ${nextTrack.title} (${nextTrack.id})`);
388
+ log.debug('PLAYER', `Prefetching metadata: ${nextTrack.title}`);
391
389
 
392
390
  try {
393
- const filtersWithVolume = {
394
- ...this._filters,
395
- volume: this._volume
396
- };
397
-
398
- this._prefetchedTrack = nextTrack;
399
- this._prefetchedStream = createStream(nextTrack, filtersWithVolume, this.config);
400
- await this._prefetchedStream.create();
401
-
402
- log.debug('PLAYER', `Prefetch ready: ${nextTrack.id}`);
391
+ // Only resolve the metadata and stream URL
392
+ const result = await this.manager.getInfo(nextTrack.id, nextTrack.source);
393
+ if (result) {
394
+ // Merge prefetch data into the existing queue object
395
+ Object.assign(nextTrack, result);
396
+ log.debug('PLAYER', `Prefetch ready (Metadata only): ${nextTrack.id}`);
397
+ }
403
398
  } catch (error) {
404
399
  log.debug('PLAYER', `Prefetch failed: ${error.message}`);
405
- this._clearPrefetch();
406
400
  } finally {
407
401
  this._prefetching = false;
408
402
  }
@@ -96,6 +96,11 @@ class StreamController {
96
96
  // Skip yt-dlp for local files
97
97
  ffmpegIn = 'pipe:0'; // We'll just pass the file path to ffmpeg -i
98
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;
99
104
  } else {
100
105
  let url;
101
106
  if (source === 'soundcloud') {
@@ -157,15 +162,31 @@ class StreamController {
157
162
  const ffmpegFilters = { ...this.filters };
158
163
  const ffmpegArgs = buildFfmpegArgs(ffmpegFilters, this.config);
159
164
 
160
- // If local, inject the input file before other args
165
+ // Input injection - only override if NOT using pipe:0 (buildFfmpegArgs already includes -i pipe:0)
161
166
  if (isLocal) {
162
167
  const filePath = this.track.absolutePath || videoId.replace('file://', '');
163
- ffmpegArgs.unshift('-i', filePath);
164
- if (seekPosition > 0) {
165
- const seekSeconds = (seekPosition / 1000).toFixed(3);
166
- ffmpegArgs.unshift('-ss', seekSeconds);
168
+ const pipeIndex = ffmpegArgs.indexOf('pipe:0');
169
+ if (pipeIndex > 0) {
170
+ ffmpegArgs[pipeIndex] = filePath;
171
+ if (seekPosition > 0) {
172
+ const seekSeconds = (seekPosition / 1000).toFixed(3);
173
+ ffmpegArgs.splice(pipeIndex - 1, 0, '-ss', seekSeconds);
174
+ }
175
+ }
176
+ } else if (ffmpegIn !== 'pipe:0') {
177
+ // Direct URL - replace the pipe:0 input with the direct URL
178
+ const pipeIndex = ffmpegArgs.indexOf('pipe:0');
179
+ if (pipeIndex > 0) {
180
+ ffmpegArgs[pipeIndex] = ffmpegIn;
181
+ if (this.track._headers) {
182
+ const headers = Object.entries(this.track._headers)
183
+ .map(([k, v]) => `${k}: ${v}`)
184
+ .join('\r\n');
185
+ ffmpegArgs.splice(pipeIndex - 1, 0, '-headers', headers);
186
+ }
167
187
  }
168
188
  }
189
+ // else: pipe:0 - buildFfmpegArgs already has -i pipe:0, nothing to change
169
190
 
170
191
  this.ffmpeg = spawn(this.config.ffmpegPath, ffmpegArgs, { env });
171
192
  this.metrics.spawn = Date.now() - spawnStart;
@@ -225,6 +246,7 @@ class StreamController {
225
246
  _waitForData(isLive = false) {
226
247
  const ffmpeg = this.ffmpeg;
227
248
  const ytdlp = this.ytdlp;
249
+ const MIN_BUFFER_SIZE = 32 * 1024;
228
250
 
229
251
  return new Promise((resolve, reject) => {
230
252
  if (!ffmpeg) return resolve();
@@ -236,28 +258,37 @@ class StreamController {
236
258
  }, timeoutMs);
237
259
 
238
260
  let resolved = false;
261
+ let bufferedSize = 0;
262
+ let firstByteRecorded = false;
239
263
 
240
- // USE READABLE EVENT: Zero-consumption way to detect data
241
- const onReadable = () => {
242
- if (!resolved) {
264
+ const checkBuffer = () => {
265
+ if (resolved) return;
266
+
267
+ if (!firstByteRecorded && ffmpeg.stdout && ffmpeg.stdout.readableLength > 0) {
268
+ firstByteRecorded = true;
269
+ this.metrics.firstByte = Date.now() - (this.startTime + this.metrics.metadata + this.metrics.spawn);
270
+ }
271
+
272
+ bufferedSize = ffmpeg.stdout ? ffmpeg.stdout.readableLength : 0;
273
+
274
+ if (bufferedSize >= MIN_BUFFER_SIZE) {
243
275
  resolved = true;
244
276
  clearTimeout(timeout);
245
- this.metrics.firstByte = Date.now() - (this.startTime + this.metrics.metadata + this.metrics.spawn);
246
- if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
277
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', checkBuffer);
247
278
  resolve();
248
279
  }
249
280
  };
250
281
 
251
282
  if (ffmpeg.stdout) {
252
- ffmpeg.stdout.on('readable', onReadable);
283
+ ffmpeg.stdout.on('readable', checkBuffer);
253
284
  }
254
285
 
255
286
  ffmpeg.on('close', () => {
256
287
  if (!resolved) {
257
288
  resolved = true;
258
289
  clearTimeout(timeout);
259
- if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
260
-
290
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', checkBuffer);
291
+
261
292
  if (this.destroyed) {
262
293
  return reject(new Error('Stream destroyed during initialization'));
263
294
  }
@@ -272,12 +303,12 @@ class StreamController {
272
303
  if (!resolved && code !== 0 && code !== null) {
273
304
  resolved = true;
274
305
  clearTimeout(timeout);
275
- if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
306
+ if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', checkBuffer);
276
307
 
277
308
  if (this.destroyed) {
278
309
  return reject(new Error('Stream destroyed during initialization'));
279
310
  }
280
-
311
+
281
312
  reject(new Error(`yt-dlp failed with code ${code}`));
282
313
  }
283
314
  });
@@ -67,14 +67,18 @@ function buildEqualizer(bands) {
67
67
  }
68
68
 
69
69
  function buildFfmpegArgs(filters = {}, config = {}) {
70
+ filters = filters || {};
70
71
  const args = [
71
72
  '-thread_queue_size', '4096',
73
+ '-probesize', '128K',
74
+ '-analyzeduration', '0',
75
+ '-fflags', '+discardcorrupt',
72
76
  '-copyts',
73
77
  '-start_at_zero',
74
78
  '-i', 'pipe:0',
75
79
  '-vn'
76
80
  ];
77
- const audioFilters = [];
81
+ const audioFilters = ['aresample=async=1:first_pts=0'];
78
82
 
79
83
  if (filters.equalizer && Array.isArray(filters.equalizer)) {
80
84
  const eq = buildEqualizer(filters.equalizer);
@@ -3,10 +3,20 @@ const log = require('../utils/logger');
3
3
  async function getInfo(url, config) {
4
4
  log.info('HTTP', `Getting info for direct URL: ${url}`);
5
5
 
6
- // For direct URLs, we can't easily get metadata without downloading or using ffprobe
7
- // For now, we'll return a basic object.
8
- // In a full implementation, we might use ffprobe here.
9
-
6
+ if (!url || typeof url !== 'string') {
7
+ throw new Error('Invalid URL: URL must be a non-empty string');
8
+ }
9
+
10
+ try {
11
+ new URL(url);
12
+ } catch (e) {
13
+ throw new Error(`Invalid URL format: ${url}`);
14
+ }
15
+
16
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
17
+ throw new Error(`Invalid URL protocol: ${url}`);
18
+ }
19
+
10
20
  const filename = url.split('/').pop().split('?')[0] || 'Direct Audio';
11
21
 
12
22
  return {
@@ -12,6 +12,11 @@ async function getInfo(filePath, config) {
12
12
  throw new Error(`File not found: ${absolutePath}`);
13
13
  }
14
14
 
15
+ const stats = fs.statSync(absolutePath);
16
+ if (stats.isDirectory()) {
17
+ throw new Error(`Path is a directory, not a file: ${absolutePath}`);
18
+ }
19
+
15
20
  const filename = path.basename(absolutePath);
16
21
 
17
22
  return {
@@ -46,6 +46,10 @@ async function search(query, limit, config, options = {}) {
46
46
  let stdout = '';
47
47
  let stderr = '';
48
48
 
49
+ proc.on('error', (err) => {
50
+ reject(new Error(`yt-dlp spawn error: ${err.message}`));
51
+ });
52
+
49
53
  proc.stdout.on('data', (data) => { stdout += data; });
50
54
  proc.stderr.on('data', (data) => { stderr += data; });
51
55
 
@@ -55,17 +59,25 @@ async function search(query, limit, config, options = {}) {
55
59
  }
56
60
  try {
57
61
  const data = JSON.parse(stdout);
58
- const tracks = (data.entries || []).map(entry => ({
59
- id: entry.id,
60
- title: entry.title,
61
- duration: entry.duration || 0,
62
- author: entry.channel || entry.uploader,
63
- thumbnail: entry.thumbnails?.[0]?.url,
64
- uri: `https://www.youtube.com/watch?v=${entry.id}`,
65
- streamUrl: `/youtube/stream/${entry.id}`,
66
- source: 'youtube',
67
- isLive: entry.live_status === 'is_live' || entry.is_live === true || !entry.duration
68
- }));
62
+ const tracks = (data.entries || []).map(entry => {
63
+ const bestAudio = entry.formats
64
+ ?.filter(f => f.vcodec === 'none' && f.acodec !== 'none')
65
+ .sort((a, b) => (b.abr || 0) - (a.abr || 0))[0];
66
+
67
+ return {
68
+ id: entry.id,
69
+ title: entry.title,
70
+ duration: entry.duration || 0,
71
+ author: entry.channel || entry.uploader,
72
+ thumbnail: entry.thumbnails?.[0]?.url,
73
+ uri: `https://www.youtube.com/watch?v=${entry.id}`,
74
+ streamUrl: `/youtube/stream/${entry.id}`,
75
+ source: 'youtube',
76
+ isLive: entry.live_status === 'is_live' || entry.is_live === true || !entry.duration,
77
+ _directUrl: bestAudio?.url || null,
78
+ _headers: entry.http_headers || data.http_headers || null
79
+ };
80
+ });
69
81
  const elapsed = Date.now() - startTime;
70
82
  log.info('YOUTUBE', `Found ${tracks.length} results (${elapsed}ms)`);
71
83
  resolve({ tracks, source: 'youtube', searchTime: elapsed });
@@ -108,6 +120,12 @@ async function getInfo(videoId, config) {
108
120
  try {
109
121
  const data = JSON.parse(stdout);
110
122
  const isLive = data.live_status === 'is_live' || data.is_live === true || !data.duration;
123
+
124
+ // Extract best audio-only format for direct piping
125
+ const bestAudio = data.formats
126
+ ?.filter(f => f.vcodec === 'none' && f.acodec !== 'none')
127
+ .sort((a, b) => (b.abr || 0) - (a.abr || 0))[0];
128
+
111
129
  resolve({
112
130
  id: data.id,
113
131
  title: data.title,
@@ -117,7 +135,9 @@ async function getInfo(videoId, config) {
117
135
  uri: data.webpage_url,
118
136
  streamUrl: `/youtube/stream/${data.id}`,
119
137
  source: 'youtube',
120
- isLive
138
+ isLive,
139
+ _directUrl: bestAudio?.url || null,
140
+ _headers: data.http_headers || null
121
141
  });
122
142
  } catch (e) {
123
143
  reject(new Error('Failed to parse yt-dlp output'));
@@ -0,0 +1,234 @@
1
+ const { describe, it, beforeEach, afterEach } = require('node:test');
2
+ const assert = require('node:assert');
3
+
4
+ const cache = require('../src/cache');
5
+
6
+ describe('Cache', () => {
7
+ beforeEach(() => {
8
+ cache.clear();
9
+ });
10
+
11
+ afterEach(() => {
12
+ cache.clear();
13
+ });
14
+
15
+ describe('set and get', () => {
16
+ it('should store and retrieve values', () => {
17
+ cache.set('key1', 'value1');
18
+ assert.strictEqual(cache.get('key1'), 'value1');
19
+ });
20
+
21
+ it('should store complex objects', () => {
22
+ const obj = { id: 1, data: { nested: true }, arr: [1, 2, 3] };
23
+ cache.set('obj', obj);
24
+ assert.deepStrictEqual(cache.get('obj'), obj);
25
+ });
26
+
27
+ it('should store arrays', () => {
28
+ const arr = [1, 2, 3, { a: 1 }];
29
+ cache.set('arr', arr);
30
+ assert.deepStrictEqual(cache.get('arr'), arr);
31
+ });
32
+
33
+ it('should store null values', () => {
34
+ cache.set('null', null);
35
+ assert.strictEqual(cache.get('null'), null);
36
+ });
37
+
38
+ it('should store zero', () => {
39
+ cache.set('zero', 0);
40
+ assert.strictEqual(cache.get('zero'), 0);
41
+ });
42
+
43
+ it('should store empty string', () => {
44
+ cache.set('empty', '');
45
+ assert.strictEqual(cache.get('empty'), '');
46
+ });
47
+
48
+ it('should store false', () => {
49
+ cache.set('false', false);
50
+ assert.strictEqual(cache.get('false'), false);
51
+ });
52
+
53
+ it('should overwrite existing values', () => {
54
+ cache.set('key', 'value1');
55
+ cache.set('key', 'value2');
56
+ assert.strictEqual(cache.get('key'), 'value2');
57
+ });
58
+
59
+ it('should return null for non-existent keys', () => {
60
+ assert.strictEqual(cache.get('nonexistent'), null);
61
+ });
62
+ });
63
+
64
+ describe('TTL expiration', () => {
65
+ it('should expire after TTL', async () => {
66
+ cache.set('expiring', 'value', 0.1);
67
+ assert.strictEqual(cache.get('expiring'), 'value');
68
+
69
+ await new Promise(r => setTimeout(r, 150));
70
+
71
+ assert.strictEqual(cache.get('expiring'), null);
72
+ });
73
+
74
+ it('should use default TTL of 300 seconds', () => {
75
+ cache.set('default', 'value');
76
+ const stats = cache.stats();
77
+ assert.strictEqual(stats.valid, 1);
78
+ });
79
+
80
+ it('should handle zero TTL', async () => {
81
+ cache.set('zero-ttl', 'value', 0);
82
+
83
+ await new Promise(r => setTimeout(r, 10));
84
+
85
+ assert.strictEqual(cache.get('zero-ttl'), null);
86
+ });
87
+
88
+ it('should handle negative TTL as immediate expiration', async () => {
89
+ cache.set('negative-ttl', 'value', -1);
90
+
91
+ await new Promise(r => setTimeout(r, 10));
92
+
93
+ assert.strictEqual(cache.get('negative-ttl'), null);
94
+ });
95
+ });
96
+
97
+ describe('del', () => {
98
+ it('should delete existing keys', () => {
99
+ cache.set('key', 'value');
100
+ cache.del('key');
101
+ assert.strictEqual(cache.get('key'), null);
102
+ });
103
+
104
+ it('should not throw for non-existent keys', () => {
105
+ assert.doesNotThrow(() => cache.del('nonexistent'));
106
+ });
107
+ });
108
+
109
+ describe('clear', () => {
110
+ it('should remove all entries', () => {
111
+ cache.set('key1', 'value1');
112
+ cache.set('key2', 'value2');
113
+ cache.set('key3', 'value3');
114
+
115
+ cache.clear();
116
+
117
+ assert.strictEqual(cache.get('key1'), null);
118
+ assert.strictEqual(cache.get('key2'), null);
119
+ assert.strictEqual(cache.get('key3'), null);
120
+ });
121
+
122
+ it('should reset stats', () => {
123
+ cache.set('key1', 'value1');
124
+ cache.clear();
125
+
126
+ const stats = cache.stats();
127
+ assert.strictEqual(stats.entries, 0);
128
+ });
129
+ });
130
+
131
+ describe('stats', () => {
132
+ it('should count valid entries', () => {
133
+ cache.set('key1', 'value1');
134
+ cache.set('key2', 'value2');
135
+
136
+ const stats = cache.stats();
137
+ assert.strictEqual(stats.entries, 2);
138
+ assert.strictEqual(stats.valid, 2);
139
+ assert.strictEqual(stats.expired, 0);
140
+ });
141
+
142
+ it('should count expired entries', async () => {
143
+ cache.set('expiring', 'value', 0.05);
144
+ cache.set('valid', 'value', 300);
145
+
146
+ await new Promise(r => setTimeout(r, 100));
147
+
148
+ const stats = cache.stats();
149
+ assert.strictEqual(stats.entries, 2);
150
+ assert.strictEqual(stats.valid, 1);
151
+ assert.strictEqual(stats.expired, 1);
152
+ });
153
+
154
+ it('should return zero for empty cache', () => {
155
+ const stats = cache.stats();
156
+ assert.strictEqual(stats.entries, 0);
157
+ assert.strictEqual(stats.valid, 0);
158
+ assert.strictEqual(stats.expired, 0);
159
+ });
160
+ });
161
+
162
+ describe('edge cases', () => {
163
+ it('should handle special characters in keys', () => {
164
+ cache.set('key:with:colons', 'value');
165
+ cache.set('key/with/slashes', 'value');
166
+ cache.set('key with spaces', 'value');
167
+ cache.set('key\nwith\nnewlines', 'value');
168
+
169
+ assert.strictEqual(cache.get('key:with:colons'), 'value');
170
+ assert.strictEqual(cache.get('key/with/slashes'), 'value');
171
+ assert.strictEqual(cache.get('key with spaces'), 'value');
172
+ assert.strictEqual(cache.get('key\nwith\nnewlines'), 'value');
173
+ });
174
+
175
+ it('should handle unicode keys', () => {
176
+ cache.set('日本語キー', 'value');
177
+ cache.set('emoji🎵key', 'value');
178
+
179
+ assert.strictEqual(cache.get('日本語キー'), 'value');
180
+ assert.strictEqual(cache.get('emoji🎵key'), 'value');
181
+ });
182
+
183
+ it('should handle very long keys', () => {
184
+ const longKey = 'a'.repeat(10000);
185
+ cache.set(longKey, 'value');
186
+ assert.strictEqual(cache.get(longKey), 'value');
187
+ });
188
+
189
+ it('should handle very large values', () => {
190
+ const largeValue = { data: 'x'.repeat(100000) };
191
+ cache.set('large', largeValue);
192
+ assert.deepStrictEqual(cache.get('large'), largeValue);
193
+ });
194
+
195
+ it('should handle undefined key gracefully', () => {
196
+ assert.strictEqual(cache.get(undefined), null);
197
+ });
198
+
199
+ it('should handle null key gracefully', () => {
200
+ assert.strictEqual(cache.get(null), null);
201
+ });
202
+ });
203
+
204
+ describe('concurrency simulation', () => {
205
+ it('should handle rapid set/get operations', () => {
206
+ for (let i = 0; i < 1000; i++) {
207
+ cache.set(`key${i}`, `value${i}`);
208
+ }
209
+
210
+ for (let i = 0; i < 1000; i++) {
211
+ assert.strictEqual(cache.get(`key${i}`), `value${i}`);
212
+ }
213
+
214
+ const stats = cache.stats();
215
+ assert.strictEqual(stats.entries, 1000);
216
+ });
217
+
218
+ it('should handle interleaved set/get/del', () => {
219
+ for (let i = 0; i < 100; i++) {
220
+ cache.set(`key${i}`, `value${i}`);
221
+ if (i % 2 === 0) {
222
+ cache.del(`key${i}`);
223
+ }
224
+ }
225
+
226
+ let found = 0;
227
+ for (let i = 0; i < 100; i++) {
228
+ if (cache.get(`key${i}`) !== null) found++;
229
+ }
230
+
231
+ assert.strictEqual(found, 50);
232
+ });
233
+ });
234
+ });