streamify-audio 2.2.5 → 2.2.7
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 +48 -16
- package/src/filters/ffmpeg.js +13 -7
- package/src/providers/http.js +14 -4
- package/src/providers/local.js +5 -0
- package/src/providers/youtube.js +72 -34
- 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.7",
|
|
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,20 +162,48 @@ 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;
|
|
172
193
|
|
|
173
194
|
if (this.ytdlp) {
|
|
195
|
+
this.ffmpeg.stdin.on('error', (err) => {
|
|
196
|
+
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
|
|
197
|
+
log.error('STREAM', `ffmpeg stdin error: ${err.message}`);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
this.ytdlp.stdout.on('error', (err) => {
|
|
202
|
+
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {
|
|
203
|
+
log.error('STREAM', `yt-dlp stdout error: ${err.message}`);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
174
207
|
this.ytdlp.stdout.pipe(this.ffmpeg.stdin);
|
|
175
208
|
|
|
176
209
|
this.ytdlp.stderr.on('data', (data) => {
|
|
@@ -211,7 +244,8 @@ class StreamController {
|
|
|
211
244
|
|
|
212
245
|
this.resource = createAudioResource(this.ffmpeg.stdout, {
|
|
213
246
|
inputType: StreamType.OggOpus,
|
|
214
|
-
inlineVolume:
|
|
247
|
+
inlineVolume: true,
|
|
248
|
+
silencePaddingFrames: 10
|
|
215
249
|
});
|
|
216
250
|
|
|
217
251
|
const elapsed = Date.now() - startTimestamp;
|
|
@@ -237,15 +271,13 @@ class StreamController {
|
|
|
237
271
|
|
|
238
272
|
let resolved = false;
|
|
239
273
|
|
|
240
|
-
// USE READABLE EVENT: Zero-consumption way to detect data
|
|
241
274
|
const onReadable = () => {
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
275
|
+
if (resolved) return;
|
|
276
|
+
resolved = true;
|
|
277
|
+
this.metrics.firstByte = Date.now() - (this.startTime + this.metrics.metadata + this.metrics.spawn);
|
|
278
|
+
clearTimeout(timeout);
|
|
279
|
+
if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
|
|
280
|
+
resolve();
|
|
249
281
|
};
|
|
250
282
|
|
|
251
283
|
if (ffmpeg.stdout) {
|
|
@@ -257,7 +289,7 @@ class StreamController {
|
|
|
257
289
|
resolved = true;
|
|
258
290
|
clearTimeout(timeout);
|
|
259
291
|
if (ffmpeg.stdout) ffmpeg.stdout.removeListener('readable', onReadable);
|
|
260
|
-
|
|
292
|
+
|
|
261
293
|
if (this.destroyed) {
|
|
262
294
|
return reject(new Error('Stream destroyed during initialization'));
|
|
263
295
|
}
|
|
@@ -277,7 +309,7 @@ class StreamController {
|
|
|
277
309
|
if (this.destroyed) {
|
|
278
310
|
return reject(new Error('Stream destroyed during initialization'));
|
|
279
311
|
}
|
|
280
|
-
|
|
312
|
+
|
|
281
313
|
reject(new Error(`yt-dlp failed with code ${code}`));
|
|
282
314
|
}
|
|
283
315
|
});
|
package/src/filters/ffmpeg.js
CHANGED
|
@@ -67,12 +67,12 @@ function buildEqualizer(bands) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
function buildFfmpegArgs(filters = {}, config = {}) {
|
|
70
|
+
filters = filters || {};
|
|
70
71
|
const args = [
|
|
71
|
-
'-thread_queue_size', '
|
|
72
|
-
'-copyts',
|
|
73
|
-
'-start_at_zero',
|
|
72
|
+
'-thread_queue_size', '512',
|
|
74
73
|
'-i', 'pipe:0',
|
|
75
|
-
'-vn'
|
|
74
|
+
'-vn',
|
|
75
|
+
'-sn'
|
|
76
76
|
];
|
|
77
77
|
const audioFilters = [];
|
|
78
78
|
|
|
@@ -247,9 +247,15 @@ function buildFfmpegArgs(filters = {}, config = {}) {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
const bitrate = config.audio?.bitrate || '128k';
|
|
250
|
-
const format = config.audio?.format || '
|
|
250
|
+
const format = config.audio?.format || 'raw';
|
|
251
251
|
|
|
252
|
-
if (format === '
|
|
252
|
+
if (format === 'raw' || format === 'pcm') {
|
|
253
|
+
args.push(
|
|
254
|
+
'-ar', '48000',
|
|
255
|
+
'-ac', '2',
|
|
256
|
+
'-f', 's16le'
|
|
257
|
+
);
|
|
258
|
+
} else if (format === 'opus') {
|
|
253
259
|
args.push(
|
|
254
260
|
'-acodec', 'libopus',
|
|
255
261
|
'-b:a', bitrate,
|
|
@@ -263,7 +269,7 @@ function buildFfmpegArgs(filters = {}, config = {}) {
|
|
|
263
269
|
} else if (format === 'aac') {
|
|
264
270
|
args.push('-acodec', 'aac', '-b:a', bitrate, '-f', 'adts');
|
|
265
271
|
} else {
|
|
266
|
-
args.push('-
|
|
272
|
+
args.push('-ar', '48000', '-ac', '2', '-f', 's16le');
|
|
267
273
|
}
|
|
268
274
|
|
|
269
275
|
args.push('-');
|
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,29 @@ 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
|
+
const authorUrl = entry.channel_url || entry.uploader_url ||
|
|
68
|
+
(entry.channel_id ? `https://www.youtube.com/channel/${entry.channel_id}` : null);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
id: entry.id,
|
|
72
|
+
title: entry.title,
|
|
73
|
+
duration: entry.duration || 0,
|
|
74
|
+
author: entry.channel || entry.uploader,
|
|
75
|
+
authorUrl,
|
|
76
|
+
thumbnail: entry.thumbnails?.[0]?.url,
|
|
77
|
+
uri: `https://www.youtube.com/watch?v=${entry.id}`,
|
|
78
|
+
streamUrl: `/youtube/stream/${entry.id}`,
|
|
79
|
+
source: 'youtube',
|
|
80
|
+
isLive: entry.live_status === 'is_live' || entry.is_live === true || !entry.duration,
|
|
81
|
+
_directUrl: bestAudio?.url || null,
|
|
82
|
+
_headers: entry.http_headers || data.http_headers || null
|
|
83
|
+
};
|
|
84
|
+
});
|
|
69
85
|
const elapsed = Date.now() - startTime;
|
|
70
86
|
log.info('YOUTUBE', `Found ${tracks.length} results (${elapsed}ms)`);
|
|
71
87
|
resolve({ tracks, source: 'youtube', searchTime: elapsed });
|
|
@@ -108,16 +124,28 @@ async function getInfo(videoId, config) {
|
|
|
108
124
|
try {
|
|
109
125
|
const data = JSON.parse(stdout);
|
|
110
126
|
const isLive = data.live_status === 'is_live' || data.is_live === true || !data.duration;
|
|
127
|
+
|
|
128
|
+
// Extract best audio-only format for direct piping
|
|
129
|
+
const bestAudio = data.formats
|
|
130
|
+
?.filter(f => f.vcodec === 'none' && f.acodec !== 'none')
|
|
131
|
+
.sort((a, b) => (b.abr || 0) - (a.abr || 0))[0];
|
|
132
|
+
|
|
133
|
+
const authorUrl = data.channel_url || data.uploader_url ||
|
|
134
|
+
(data.channel_id ? `https://www.youtube.com/channel/${data.channel_id}` : null);
|
|
135
|
+
|
|
111
136
|
resolve({
|
|
112
137
|
id: data.id,
|
|
113
138
|
title: data.title,
|
|
114
139
|
duration: data.duration || 0,
|
|
115
140
|
author: data.channel || data.uploader,
|
|
141
|
+
authorUrl,
|
|
116
142
|
thumbnail: data.thumbnail,
|
|
117
143
|
uri: data.webpage_url,
|
|
118
144
|
streamUrl: `/youtube/stream/${data.id}`,
|
|
119
145
|
source: 'youtube',
|
|
120
|
-
isLive
|
|
146
|
+
isLive,
|
|
147
|
+
_directUrl: bestAudio?.url || null,
|
|
148
|
+
_headers: data.http_headers || null
|
|
121
149
|
});
|
|
122
150
|
} catch (e) {
|
|
123
151
|
reject(new Error('Failed to parse yt-dlp output'));
|
|
@@ -259,17 +287,22 @@ async function getPlaylist(playlistId, config) {
|
|
|
259
287
|
}
|
|
260
288
|
try {
|
|
261
289
|
const data = JSON.parse(stdout);
|
|
262
|
-
const tracks = (data.entries || []).map(entry =>
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
290
|
+
const tracks = (data.entries || []).map(entry => {
|
|
291
|
+
const authorUrl = entry.channel_url || entry.uploader_url ||
|
|
292
|
+
(entry.channel_id ? `https://www.youtube.com/channel/${entry.channel_id}` : null);
|
|
293
|
+
return {
|
|
294
|
+
id: entry.id,
|
|
295
|
+
title: entry.title,
|
|
296
|
+
duration: entry.duration || 0,
|
|
297
|
+
author: entry.channel || entry.uploader,
|
|
298
|
+
authorUrl,
|
|
299
|
+
thumbnail: entry.thumbnails?.[0]?.url,
|
|
300
|
+
uri: `https://www.youtube.com/watch?v=${entry.id}`,
|
|
301
|
+
streamUrl: `/youtube/stream/${entry.id}`,
|
|
302
|
+
source: 'youtube',
|
|
303
|
+
isLive: entry.live_status === 'is_live' || entry.is_live === true || !entry.duration
|
|
304
|
+
};
|
|
305
|
+
});
|
|
273
306
|
log.info('YOUTUBE', `Playlist loaded: ${data.title || playlistId} (${tracks.length} tracks)`);
|
|
274
307
|
resolve({
|
|
275
308
|
id: playlistId,
|
|
@@ -322,17 +355,22 @@ async function getRelated(videoId, limit, config) {
|
|
|
322
355
|
const tracks = entries
|
|
323
356
|
.filter(entry => entry.id !== videoId)
|
|
324
357
|
.slice(0, limit)
|
|
325
|
-
.map(entry =>
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
358
|
+
.map(entry => {
|
|
359
|
+
const authorUrl = entry.channel_url || entry.uploader_url ||
|
|
360
|
+
(entry.channel_id ? `https://www.youtube.com/channel/${entry.channel_id}` : null);
|
|
361
|
+
return {
|
|
362
|
+
id: entry.id,
|
|
363
|
+
title: entry.title,
|
|
364
|
+
duration: entry.duration,
|
|
365
|
+
author: entry.channel || entry.uploader,
|
|
366
|
+
authorUrl,
|
|
367
|
+
thumbnail: entry.thumbnails?.[0]?.url,
|
|
368
|
+
uri: `https://www.youtube.com/watch?v=${entry.id}`,
|
|
369
|
+
streamUrl: `/youtube/stream/${entry.id}`,
|
|
370
|
+
source: 'youtube',
|
|
371
|
+
isAutoplay: true
|
|
372
|
+
};
|
|
373
|
+
});
|
|
336
374
|
log.info('YOUTUBE', `Found ${tracks.length} related tracks`);
|
|
337
375
|
resolve({ tracks, source: 'youtube' });
|
|
338
376
|
} catch (e) {
|
|
@@ -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
|
+
});
|