@xiboplayer/cache 0.3.4 → 0.3.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": "@xiboplayer/cache",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Offline caching and download management with parallel chunk downloads",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "spark-md5": "^3.0.2",
15
- "@xiboplayer/utils": "0.3.4"
15
+ "@xiboplayer/utils": "0.3.6"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0",
@@ -0,0 +1,176 @@
1
+ /**
2
+ * CacheAnalyzer - Stale media detection and storage health monitoring
3
+ *
4
+ * Compares cached files against RequiredFiles from the CMS to identify
5
+ * orphaned media that is no longer needed. Logs a summary every collection
6
+ * cycle. Only evicts when storage pressure exceeds a configurable threshold.
7
+ *
8
+ * Works entirely through CacheProxy (postMessage to SW) — no IndexedDB,
9
+ * no direct Cache API access.
10
+ */
11
+
12
+ import { createLogger } from '@xiboplayer/utils';
13
+
14
+ const log = createLogger('CacheAnalyzer');
15
+
16
+ /**
17
+ * Format bytes into human-readable string (e.g. 1.2 GB, 350 MB)
18
+ */
19
+ function formatBytes(bytes) {
20
+ if (bytes === 0) return '0 B';
21
+ if (!Number.isFinite(bytes)) return '∞';
22
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
23
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
24
+ const value = bytes / Math.pow(1024, i);
25
+ return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
26
+ }
27
+
28
+ export class CacheAnalyzer {
29
+ /**
30
+ * @param {import('./cache-proxy.js').CacheProxy} cache - CacheProxy instance
31
+ * @param {object} [options]
32
+ * @param {number} [options.threshold=80] - Storage usage % above which eviction triggers
33
+ */
34
+ constructor(cache, { threshold = 80 } = {}) {
35
+ this.cache = cache;
36
+ this.threshold = threshold;
37
+ }
38
+
39
+ /**
40
+ * Analyze cache health by comparing cached files against required files.
41
+ *
42
+ * @param {Array<{id: string, type: string}>} requiredFiles - Current RequiredFiles from CMS
43
+ * @returns {Promise<object>} Analysis report
44
+ */
45
+ async analyze(requiredFiles) {
46
+ const cachedFiles = await this.cache.getAllFiles();
47
+ const storage = await this._getStorageEstimate();
48
+
49
+ // Build set of required file IDs (as strings for consistent comparison)
50
+ const requiredIds = new Set(requiredFiles.map(f => String(f.id)));
51
+
52
+ // Categorize cached files
53
+ const required = [];
54
+ const orphaned = [];
55
+
56
+ for (const file of cachedFiles) {
57
+ if (requiredIds.has(String(file.id))) {
58
+ required.push(file);
59
+ } else {
60
+ orphaned.push(file);
61
+ }
62
+ }
63
+
64
+ // Sort orphaned by cachedAt ascending (oldest first — evict these first)
65
+ orphaned.sort((a, b) => (a.cachedAt || 0) - (b.cachedAt || 0));
66
+
67
+ const orphanedSize = orphaned.reduce((sum, f) => sum + (f.size || 0), 0);
68
+
69
+ const report = {
70
+ timestamp: Date.now(),
71
+ storage: {
72
+ usage: storage.usage,
73
+ quota: storage.quota,
74
+ percent: storage.quota > 0 ? Math.round((storage.usage / storage.quota) * 100) : 0,
75
+ },
76
+ files: {
77
+ required: required.length,
78
+ orphaned: orphaned.length,
79
+ total: cachedFiles.length,
80
+ },
81
+ orphaned: orphaned.map(f => ({
82
+ id: f.id,
83
+ type: f.type,
84
+ size: f.size || 0,
85
+ cachedAt: f.cachedAt || 0,
86
+ })),
87
+ orphanedSize,
88
+ evicted: [],
89
+ threshold: this.threshold,
90
+ };
91
+
92
+ // Log summary
93
+ log.info(`Storage: ${formatBytes(storage.usage)} / ${formatBytes(storage.quota)} (${report.storage.percent}%)`);
94
+ log.info(`Cache: ${required.length} required, ${orphaned.length} orphaned (${formatBytes(orphanedSize)} reclaimable)`);
95
+
96
+ if (orphaned.length > 0) {
97
+ for (const f of orphaned) {
98
+ const age = Date.now() - (f.cachedAt || 0);
99
+ const days = Math.floor(age / 86400000);
100
+ const hours = Math.floor((age % 86400000) / 3600000);
101
+ const ageStr = days > 0 ? `${days}d ago` : `${hours}h ago`;
102
+ log.info(` Orphaned: ${f.type}/${f.id} (${formatBytes(f.size || 0)}, cached ${ageStr})`);
103
+ }
104
+ }
105
+
106
+ // Evict only when storage exceeds threshold
107
+ if (report.storage.percent > this.threshold && orphaned.length > 0) {
108
+ log.warn(`Storage exceeds ${this.threshold}% threshold — evicting orphaned files`);
109
+ const targetBytes = storage.usage - (storage.quota * this.threshold / 100);
110
+ report.evicted = await this._evict(orphaned, targetBytes);
111
+ } else {
112
+ log.info(`No eviction needed (threshold: ${this.threshold}%)`);
113
+ }
114
+
115
+ return report;
116
+ }
117
+
118
+ /**
119
+ * Get storage estimate from the browser.
120
+ * Falls back to { usage: 0, quota: Infinity } in environments without the API.
121
+ */
122
+ async _getStorageEstimate() {
123
+ try {
124
+ if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
125
+ const { usage, quota } = await navigator.storage.estimate();
126
+ return { usage: usage || 0, quota: quota || Infinity };
127
+ }
128
+ } catch (e) {
129
+ log.warn('Storage estimate unavailable:', e.message);
130
+ }
131
+ return { usage: 0, quota: Infinity };
132
+ }
133
+
134
+ /**
135
+ * Evict orphaned files (oldest first) until targetBytes are freed.
136
+ * Delegates deletion to CacheProxy.deleteFiles() which routes to SW.
137
+ *
138
+ * @param {Array} orphanedFiles - Files to evict, sorted oldest-first
139
+ * @param {number} targetBytes - Bytes to free
140
+ * @returns {Promise<Array>} Evicted file records
141
+ */
142
+ async _evict(orphanedFiles, targetBytes) {
143
+ const toEvict = [];
144
+ let plannedBytes = 0;
145
+
146
+ for (const file of orphanedFiles) {
147
+ if (plannedBytes >= targetBytes) break;
148
+ toEvict.push(file);
149
+ plannedBytes += file.size || 0;
150
+ }
151
+
152
+ if (toEvict.length === 0) return [];
153
+
154
+ try {
155
+ const filesToDelete = toEvict.map(f => ({ type: f.type, id: f.id }));
156
+ await this.cache.deleteFiles(filesToDelete);
157
+
158
+ for (const f of toEvict) {
159
+ log.info(` Evicted: ${f.type}/${f.id} (${formatBytes(f.size || 0)})`);
160
+ }
161
+ log.info(`Evicted ${toEvict.length} files, freed ${formatBytes(plannedBytes)}`);
162
+ } catch (err) {
163
+ log.warn('Eviction failed:', err.message);
164
+ return [];
165
+ }
166
+
167
+ return toEvict.map(f => ({
168
+ id: f.id,
169
+ type: f.type,
170
+ size: f.size || 0,
171
+ cachedAt: f.cachedAt || 0,
172
+ }));
173
+ }
174
+ }
175
+
176
+ export { formatBytes };
@@ -0,0 +1,270 @@
1
+ /**
2
+ * CacheAnalyzer Tests
3
+ *
4
+ * Tests for stale media detection, storage health reporting, and eviction logic.
5
+ * Mock follows the CacheProxy interface (getAllFiles, deleteFiles).
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
9
+ import { CacheAnalyzer, formatBytes } from './cache-analyzer.js';
10
+
11
+ describe('CacheAnalyzer', () => {
12
+ let analyzer;
13
+ let mockCache;
14
+
15
+ beforeEach(() => {
16
+ // Mock CacheProxy with in-memory file store
17
+ const files = new Map();
18
+
19
+ mockCache = {
20
+ getAllFiles: vi.fn(async () => [...files.values()]),
21
+ deleteFiles: vi.fn(async (filesToDelete) => ({
22
+ deleted: filesToDelete.length,
23
+ total: filesToDelete.length,
24
+ })),
25
+ // Helper to add test files
26
+ _files: files,
27
+ _addFile(record) {
28
+ files.set(record.id, record);
29
+ },
30
+ };
31
+
32
+ analyzer = new CacheAnalyzer(mockCache);
33
+ });
34
+
35
+ describe('formatBytes', () => {
36
+ it('should format bytes correctly', () => {
37
+ expect(formatBytes(0)).toBe('0 B');
38
+ expect(formatBytes(1024)).toBe('1.0 KB');
39
+ expect(formatBytes(1024 * 1024)).toBe('1.0 MB');
40
+ expect(formatBytes(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB');
41
+ expect(formatBytes(Infinity)).toBe('∞');
42
+ });
43
+ });
44
+
45
+ describe('analyze', () => {
46
+ it('should categorize required vs orphaned files', async () => {
47
+ mockCache._addFile({ id: '1', type: 'media', size: 1000, cachedAt: 100 });
48
+ mockCache._addFile({ id: '2', type: 'media', size: 2000, cachedAt: 200 });
49
+ mockCache._addFile({ id: '3', type: 'layout', size: 500, cachedAt: 300 });
50
+
51
+ const requiredFiles = [
52
+ { id: '1', type: 'media' },
53
+ { id: '3', type: 'layout' },
54
+ ];
55
+
56
+ const report = await analyzer.analyze(requiredFiles);
57
+
58
+ expect(report.files.required).toBe(2);
59
+ expect(report.files.orphaned).toBe(1);
60
+ expect(report.files.total).toBe(3);
61
+ expect(report.orphaned).toHaveLength(1);
62
+ expect(report.orphaned[0].id).toBe('2');
63
+ expect(report.orphanedSize).toBe(2000);
64
+ });
65
+
66
+ it('should report zero orphaned when all files are required', async () => {
67
+ mockCache._addFile({ id: '1', type: 'media', size: 1000, cachedAt: 100 });
68
+ mockCache._addFile({ id: '2', type: 'media', size: 2000, cachedAt: 200 });
69
+
70
+ const requiredFiles = [
71
+ { id: '1', type: 'media' },
72
+ { id: '2', type: 'media' },
73
+ ];
74
+
75
+ const report = await analyzer.analyze(requiredFiles);
76
+
77
+ expect(report.files.required).toBe(2);
78
+ expect(report.files.orphaned).toBe(0);
79
+ expect(report.orphaned).toHaveLength(0);
80
+ expect(report.orphanedSize).toBe(0);
81
+ expect(report.evicted).toHaveLength(0);
82
+ });
83
+
84
+ it('should handle empty cache', async () => {
85
+ const report = await analyzer.analyze([{ id: '1', type: 'media' }]);
86
+
87
+ expect(report.files.required).toBe(0);
88
+ expect(report.files.orphaned).toBe(0);
89
+ expect(report.files.total).toBe(0);
90
+ });
91
+
92
+ it('should handle empty required files list', async () => {
93
+ mockCache._addFile({ id: '1', type: 'media', size: 5000, cachedAt: 100 });
94
+
95
+ const report = await analyzer.analyze([]);
96
+
97
+ expect(report.files.required).toBe(0);
98
+ expect(report.files.orphaned).toBe(1);
99
+ expect(report.orphanedSize).toBe(5000);
100
+ });
101
+
102
+ it('should sort orphaned files oldest first', async () => {
103
+ mockCache._addFile({ id: 'new', type: 'media', size: 100, cachedAt: 3000 });
104
+ mockCache._addFile({ id: 'old', type: 'media', size: 100, cachedAt: 1000 });
105
+ mockCache._addFile({ id: 'mid', type: 'media', size: 100, cachedAt: 2000 });
106
+
107
+ const report = await analyzer.analyze([]);
108
+
109
+ expect(report.orphaned[0].id).toBe('old');
110
+ expect(report.orphaned[1].id).toBe('mid');
111
+ expect(report.orphaned[2].id).toBe('new');
112
+ });
113
+
114
+ it('should compare IDs as strings for mixed types', async () => {
115
+ mockCache._addFile({ id: '42', type: 'media', size: 100, cachedAt: 100 });
116
+
117
+ // RequiredFiles may use numeric IDs
118
+ const report = await analyzer.analyze([{ id: 42, type: 'media' }]);
119
+
120
+ expect(report.files.required).toBe(1);
121
+ expect(report.files.orphaned).toBe(0);
122
+ });
123
+
124
+ it('should not evict when storage is under threshold', async () => {
125
+ mockCache._addFile({ id: '1', type: 'media', size: 100, cachedAt: 100 });
126
+
127
+ // Mock storage at 50% (under default 80% threshold)
128
+ vi.stubGlobal('navigator', {
129
+ storage: {
130
+ estimate: vi.fn(async () => ({ usage: 500, quota: 1000 })),
131
+ },
132
+ });
133
+
134
+ const report = await analyzer.analyze([]);
135
+
136
+ expect(report.storage.percent).toBe(50);
137
+ expect(report.evicted).toHaveLength(0);
138
+
139
+ vi.unstubAllGlobals();
140
+ });
141
+
142
+ it('should include storage info in report', async () => {
143
+ vi.stubGlobal('navigator', {
144
+ storage: {
145
+ estimate: vi.fn(async () => ({ usage: 2_000_000_000, quota: 5_000_000_000 })),
146
+ },
147
+ });
148
+
149
+ const report = await analyzer.analyze([]);
150
+
151
+ expect(report.storage.usage).toBe(2_000_000_000);
152
+ expect(report.storage.quota).toBe(5_000_000_000);
153
+ expect(report.storage.percent).toBe(40);
154
+ expect(report.threshold).toBe(80);
155
+
156
+ vi.unstubAllGlobals();
157
+ });
158
+
159
+ it('should handle missing storage API gracefully', async () => {
160
+ // No navigator.storage
161
+ vi.stubGlobal('navigator', {});
162
+
163
+ const report = await analyzer.analyze([]);
164
+
165
+ expect(report.storage.usage).toBe(0);
166
+ expect(report.storage.quota).toBe(Infinity);
167
+ expect(report.storage.percent).toBe(0);
168
+ expect(report.evicted).toHaveLength(0);
169
+
170
+ vi.unstubAllGlobals();
171
+ });
172
+
173
+ it('should handle files with missing size or cachedAt', async () => {
174
+ mockCache._addFile({ id: '1', type: 'media' }); // no size, no cachedAt
175
+
176
+ const report = await analyzer.analyze([]);
177
+
178
+ expect(report.files.orphaned).toBe(1);
179
+ expect(report.orphanedSize).toBe(0);
180
+ expect(report.orphaned[0].size).toBe(0);
181
+ expect(report.orphaned[0].cachedAt).toBe(0);
182
+ });
183
+ });
184
+
185
+ describe('eviction', () => {
186
+ it('should evict orphaned files when storage exceeds threshold', async () => {
187
+ mockCache._addFile({ id: 'old', type: 'media', size: 500, cachedAt: 1000 });
188
+ mockCache._addFile({ id: 'newer', type: 'media', size: 300, cachedAt: 2000 });
189
+ mockCache._addFile({ id: 'required', type: 'media', size: 1000, cachedAt: 500 });
190
+
191
+ // 90% usage — exceeds 80% threshold
192
+ vi.stubGlobal('navigator', {
193
+ storage: {
194
+ estimate: vi.fn(async () => ({ usage: 9000, quota: 10000 })),
195
+ },
196
+ });
197
+
198
+ const report = await analyzer.analyze([{ id: 'required', type: 'media' }]);
199
+
200
+ expect(report.evicted.length).toBeGreaterThan(0);
201
+ // Should evict oldest first
202
+ expect(report.evicted[0].id).toBe('old');
203
+ // deleteFiles should be called on the cache proxy
204
+ expect(mockCache.deleteFiles).toHaveBeenCalled();
205
+
206
+ vi.unstubAllGlobals();
207
+ });
208
+
209
+ it('should stop evicting once enough space is freed', async () => {
210
+ // 3 orphaned files, but only need to free a small amount
211
+ mockCache._addFile({ id: 'a', type: 'media', size: 2000, cachedAt: 1000 });
212
+ mockCache._addFile({ id: 'b', type: 'media', size: 2000, cachedAt: 2000 });
213
+ mockCache._addFile({ id: 'c', type: 'media', size: 2000, cachedAt: 3000 });
214
+
215
+ // 85% usage, threshold 80% → need to free 5% of 10000 = 500 bytes
216
+ // First file (2000 bytes) should be enough
217
+ vi.stubGlobal('navigator', {
218
+ storage: {
219
+ estimate: vi.fn(async () => ({ usage: 8500, quota: 10000 })),
220
+ },
221
+ });
222
+
223
+ const report = await analyzer.analyze([]);
224
+
225
+ expect(report.evicted).toHaveLength(1);
226
+ expect(report.evicted[0].id).toBe('a'); // oldest
227
+
228
+ vi.unstubAllGlobals();
229
+ });
230
+
231
+ it('should never evict required files', async () => {
232
+ mockCache._addFile({ id: 'keep', type: 'media', size: 5000, cachedAt: 100 });
233
+ mockCache._addFile({ id: 'orphan', type: 'media', size: 100, cachedAt: 200 });
234
+
235
+ vi.stubGlobal('navigator', {
236
+ storage: {
237
+ estimate: vi.fn(async () => ({ usage: 9500, quota: 10000 })),
238
+ },
239
+ });
240
+
241
+ const report = await analyzer.analyze([{ id: 'keep', type: 'media' }]);
242
+
243
+ // Only the orphan can be evicted, even though 'keep' is older and larger
244
+ const evictedIds = report.evicted.map(f => f.id);
245
+ expect(evictedIds).not.toContain('keep');
246
+
247
+ vi.unstubAllGlobals();
248
+ });
249
+
250
+ it('should respect custom threshold', async () => {
251
+ analyzer = new CacheAnalyzer(mockCache, { threshold: 50 });
252
+
253
+ mockCache._addFile({ id: '1', type: 'media', size: 100, cachedAt: 100 });
254
+
255
+ // 60% usage — under 80% but over 50%
256
+ vi.stubGlobal('navigator', {
257
+ storage: {
258
+ estimate: vi.fn(async () => ({ usage: 6000, quota: 10000 })),
259
+ },
260
+ });
261
+
262
+ const report = await analyzer.analyze([]);
263
+
264
+ expect(report.threshold).toBe(50);
265
+ expect(report.evicted.length).toBeGreaterThan(0);
266
+
267
+ vi.unstubAllGlobals();
268
+ });
269
+ });
270
+ });
@@ -416,6 +416,32 @@ export class CacheProxy extends EventEmitter {
416
416
  return this.backendType === 'service-worker';
417
417
  }
418
418
 
419
+ /**
420
+ * Get all cached files from Service Worker
421
+ * @returns {Promise<Array<{id: string, type: string, size: number, cachedAt: number}>>}
422
+ */
423
+ async getAllFiles() {
424
+ if (!this.backend) {
425
+ throw new Error('CacheProxy not initialized');
426
+ }
427
+
428
+ return new Promise((resolve) => {
429
+ const channel = new MessageChannel();
430
+
431
+ channel.port1.onmessage = (event) => {
432
+ const { success, files } = event.data;
433
+ resolve(success ? files : []);
434
+ };
435
+
436
+ navigator.serviceWorker.controller.postMessage(
437
+ { type: 'GET_ALL_FILES' },
438
+ [channel.port2]
439
+ );
440
+
441
+ setTimeout(() => resolve([]), 5000);
442
+ });
443
+ }
444
+
419
445
  /**
420
446
  * Delete files from cache (purge obsolete media)
421
447
  * @param {Array<{type: string, id: string}>} files - Files to delete