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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "streamify-audio",
3
- "version": "2.2.5",
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",
@@ -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,20 +162,48 @@ 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;
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: false
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 (!resolved) {
243
- resolved = true;
244
- 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);
247
- resolve();
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
  });
@@ -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', '4096',
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 || 'opus';
250
+ const format = config.audio?.format || 'raw';
251
251
 
252
- if (format === 'opus') {
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('-acodec', 'libopus', '-b:a', bitrate, '-f', 'ogg');
272
+ args.push('-ar', '48000', '-ac', '2', '-f', 's16le');
267
273
  }
268
274
 
269
275
  args.push('-');
@@ -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,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
- 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
+ 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
- id: entry.id,
264
- title: entry.title,
265
- duration: entry.duration || 0,
266
- author: entry.channel || entry.uploader,
267
- thumbnail: entry.thumbnails?.[0]?.url,
268
- uri: `https://www.youtube.com/watch?v=${entry.id}`,
269
- streamUrl: `/youtube/stream/${entry.id}`,
270
- source: 'youtube',
271
- isLive: entry.live_status === 'is_live' || entry.is_live === true || !entry.duration
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
- id: entry.id,
327
- title: entry.title,
328
- duration: entry.duration,
329
- author: entry.channel || entry.uploader,
330
- thumbnail: entry.thumbnails?.[0]?.url,
331
- uri: `https://www.youtube.com/watch?v=${entry.id}`,
332
- streamUrl: `/youtube/stream/${entry.id}`,
333
- source: 'youtube',
334
- isAutoplay: true
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
+ });