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 +1 -1
- package/src/cache/index.js +2 -1
- package/src/config.js +1 -0
- package/src/discord/Player.js +9 -15
- package/src/discord/Stream.js +46 -15
- package/src/filters/ffmpeg.js +5 -1
- package/src/providers/http.js +14 -4
- package/src/providers/local.js +5 -0
- package/src/providers/youtube.js +32 -12
- package/tests/cache.test.js +234 -0
- package/tests/error-handling.test.js +318 -0
- package/tests/filters-edge.test.js +333 -0
- package/tests/integration.test.js +325 -0
- package/tests/spotify.test.js +238 -0
- package/tests/stream.test.js +217 -0
- package/tests/youtube.test.js +219 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "streamify-audio",
|
|
3
|
-
"version": "2.2.
|
|
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",
|
package/src/cache/index.js
CHANGED
package/src/config.js
CHANGED
package/src/discord/Player.js
CHANGED
|
@@ -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 ||
|
|
385
|
+
if (!nextTrack || nextTrack._directUrl) return; // Already prefetched or resolved
|
|
386
386
|
|
|
387
387
|
this._prefetching = true;
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
log.debug('PLAYER', `Prefetching: ${nextTrack.title} (${nextTrack.id})`);
|
|
388
|
+
log.debug('PLAYER', `Prefetching metadata: ${nextTrack.title}`);
|
|
391
389
|
|
|
392
390
|
try {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
}
|
package/src/discord/Stream.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
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
|
});
|
package/src/filters/ffmpeg.js
CHANGED
|
@@ -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);
|
package/src/providers/http.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 {
|
package/src/providers/local.js
CHANGED
|
@@ -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 {
|
package/src/providers/youtube.js
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
});
|