@xiboplayer/cache 0.3.1 → 0.3.5

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.1",
3
+ "version": "0.3.5",
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.1"
15
+ "@xiboplayer/utils": "0.3.5"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0",
@@ -0,0 +1,189 @@
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
+
9
+ import { createLogger } from '@xiboplayer/utils';
10
+
11
+ const log = createLogger('CacheAnalyzer');
12
+
13
+ /**
14
+ * Format bytes into human-readable string (e.g. 1.2 GB, 350 MB)
15
+ */
16
+ function formatBytes(bytes) {
17
+ if (bytes === 0) return '0 B';
18
+ if (!Number.isFinite(bytes)) return '∞';
19
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
20
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
21
+ const value = bytes / Math.pow(1024, i);
22
+ return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
23
+ }
24
+
25
+ export class CacheAnalyzer {
26
+ /**
27
+ * @param {import('./cache.js').CacheManager} cache - CacheManager instance
28
+ * @param {object} [options]
29
+ * @param {number} [options.threshold=80] - Storage usage % above which eviction triggers
30
+ */
31
+ constructor(cache, { threshold = 80 } = {}) {
32
+ this.cache = cache;
33
+ this.threshold = threshold;
34
+ }
35
+
36
+ /**
37
+ * Analyze cache health by comparing cached files against required files.
38
+ *
39
+ * @param {Array<{id: string, type: string}>} requiredFiles - Current RequiredFiles from CMS
40
+ * @returns {Promise<object>} Analysis report
41
+ */
42
+ async analyze(requiredFiles) {
43
+ const cachedFiles = await this.cache.getAllFiles();
44
+ const storage = await this._getStorageEstimate();
45
+
46
+ // Build set of required file IDs (as strings for consistent comparison)
47
+ const requiredIds = new Set(requiredFiles.map(f => String(f.id)));
48
+
49
+ // Categorize cached files
50
+ const required = [];
51
+ const orphaned = [];
52
+
53
+ for (const file of cachedFiles) {
54
+ if (requiredIds.has(String(file.id))) {
55
+ required.push(file);
56
+ } else {
57
+ orphaned.push(file);
58
+ }
59
+ }
60
+
61
+ // Sort orphaned by cachedAt ascending (oldest first — evict these first)
62
+ orphaned.sort((a, b) => (a.cachedAt || 0) - (b.cachedAt || 0));
63
+
64
+ const orphanedSize = orphaned.reduce((sum, f) => sum + (f.size || 0), 0);
65
+
66
+ const report = {
67
+ timestamp: Date.now(),
68
+ storage: {
69
+ usage: storage.usage,
70
+ quota: storage.quota,
71
+ percent: storage.quota > 0 ? Math.round((storage.usage / storage.quota) * 100) : 0,
72
+ },
73
+ files: {
74
+ required: required.length,
75
+ orphaned: orphaned.length,
76
+ total: cachedFiles.length,
77
+ },
78
+ orphaned: orphaned.map(f => ({
79
+ id: f.id,
80
+ type: f.type,
81
+ size: f.size || 0,
82
+ cachedAt: f.cachedAt || 0,
83
+ })),
84
+ orphanedSize,
85
+ evicted: [],
86
+ threshold: this.threshold,
87
+ };
88
+
89
+ // Log summary
90
+ log.info(`Storage: ${formatBytes(storage.usage)} / ${formatBytes(storage.quota)} (${report.storage.percent}%)`);
91
+ log.info(`Cache: ${required.length} required, ${orphaned.length} orphaned (${formatBytes(orphanedSize)} reclaimable)`);
92
+
93
+ if (orphaned.length > 0) {
94
+ for (const f of orphaned) {
95
+ const age = Date.now() - (f.cachedAt || 0);
96
+ const days = Math.floor(age / 86400000);
97
+ const hours = Math.floor((age % 86400000) / 3600000);
98
+ const ageStr = days > 0 ? `${days}d ago` : `${hours}h ago`;
99
+ log.info(` Orphaned: ${f.type}/${f.id} (${formatBytes(f.size || 0)}, cached ${ageStr})`);
100
+ }
101
+ }
102
+
103
+ // Evict only when storage exceeds threshold
104
+ if (report.storage.percent > this.threshold && orphaned.length > 0) {
105
+ log.warn(`Storage exceeds ${this.threshold}% threshold — evicting orphaned files`);
106
+ const targetBytes = storage.usage - (storage.quota * this.threshold / 100);
107
+ report.evicted = await this._evict(orphaned, targetBytes);
108
+ } else {
109
+ log.info(`No eviction needed (threshold: ${this.threshold}%)`);
110
+ }
111
+
112
+ return report;
113
+ }
114
+
115
+ /**
116
+ * Get storage estimate from the browser.
117
+ * Falls back to { usage: 0, quota: Infinity } in environments without the API.
118
+ */
119
+ async _getStorageEstimate() {
120
+ try {
121
+ if (typeof navigator !== 'undefined' && navigator.storage?.estimate) {
122
+ const { usage, quota } = await navigator.storage.estimate();
123
+ return { usage: usage || 0, quota: quota || Infinity };
124
+ }
125
+ } catch (e) {
126
+ log.warn('Storage estimate unavailable:', e.message);
127
+ }
128
+ return { usage: 0, quota: Infinity };
129
+ }
130
+
131
+ /**
132
+ * Evict orphaned files (oldest first) until targetBytes are freed.
133
+ *
134
+ * Deletes from both IndexedDB metadata and Cache API.
135
+ *
136
+ * @param {Array} orphanedFiles - Files to evict, sorted oldest-first
137
+ * @param {number} targetBytes - Bytes to free
138
+ * @returns {Promise<Array>} Evicted file records
139
+ */
140
+ async _evict(orphanedFiles, targetBytes) {
141
+ const evicted = [];
142
+ let freedBytes = 0;
143
+
144
+ for (const file of orphanedFiles) {
145
+ if (freedBytes >= targetBytes) break;
146
+
147
+ try {
148
+ // Delete from IndexedDB metadata
149
+ await this._deleteFileMetadata(file.id);
150
+
151
+ // Delete from Cache API
152
+ const cacheKey = this.cache.getCacheKey(file.type, file.id);
153
+ if (this.cache.cache) {
154
+ await this.cache.cache.delete(cacheKey);
155
+ }
156
+
157
+ freedBytes += file.size || 0;
158
+ evicted.push({
159
+ id: file.id,
160
+ type: file.type,
161
+ size: file.size || 0,
162
+ cachedAt: file.cachedAt || 0,
163
+ });
164
+
165
+ log.info(` Evicted: ${file.type}/${file.id} (${formatBytes(file.size || 0)})`);
166
+ } catch (err) {
167
+ log.warn(` Failed to evict ${file.type}/${file.id}:`, err.message);
168
+ }
169
+ }
170
+
171
+ log.info(`Evicted ${evicted.length} files, freed ${formatBytes(freedBytes)}`);
172
+ return evicted;
173
+ }
174
+
175
+ /**
176
+ * Delete a file record from IndexedDB.
177
+ */
178
+ async _deleteFileMetadata(id) {
179
+ return new Promise((resolve, reject) => {
180
+ const tx = this.cache.db.transaction('files', 'readwrite');
181
+ const store = tx.objectStore('files');
182
+ const request = store.delete(id);
183
+ request.onsuccess = () => resolve();
184
+ request.onerror = () => reject(request.error);
185
+ });
186
+ }
187
+ }
188
+
189
+ export { formatBytes };
@@ -0,0 +1,291 @@
1
+ /**
2
+ * CacheAnalyzer Tests
3
+ *
4
+ * Tests for stale media detection, storage health reporting, and eviction logic.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
8
+ import { CacheAnalyzer, formatBytes } from './cache-analyzer.js';
9
+
10
+ describe('CacheAnalyzer', () => {
11
+ let analyzer;
12
+ let mockCache;
13
+
14
+ beforeEach(() => {
15
+ // Mock CacheManager with in-memory file store
16
+ const files = new Map();
17
+ const cacheStore = new Map();
18
+
19
+ mockCache = {
20
+ db: null, // Will be set up per-test if eviction is tested
21
+ cache: {
22
+ delete: vi.fn(async (key) => cacheStore.delete(key)),
23
+ },
24
+ getAllFiles: vi.fn(async () => [...files.values()]),
25
+ getCacheKey: vi.fn((type, id) => `/player/pwa/cache/${type}/${id}`),
26
+ // Helper to add test files
27
+ _files: files,
28
+ _addFile(record) {
29
+ files.set(record.id, record);
30
+ },
31
+ };
32
+
33
+ analyzer = new CacheAnalyzer(mockCache);
34
+ });
35
+
36
+ describe('formatBytes', () => {
37
+ it('should format bytes correctly', () => {
38
+ expect(formatBytes(0)).toBe('0 B');
39
+ expect(formatBytes(1024)).toBe('1.0 KB');
40
+ expect(formatBytes(1024 * 1024)).toBe('1.0 MB');
41
+ expect(formatBytes(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB');
42
+ expect(formatBytes(Infinity)).toBe('∞');
43
+ });
44
+ });
45
+
46
+ describe('analyze', () => {
47
+ it('should categorize required vs orphaned files', async () => {
48
+ mockCache._addFile({ id: '1', type: 'media', size: 1000, cachedAt: 100 });
49
+ mockCache._addFile({ id: '2', type: 'media', size: 2000, cachedAt: 200 });
50
+ mockCache._addFile({ id: '3', type: 'layout', size: 500, cachedAt: 300 });
51
+
52
+ const requiredFiles = [
53
+ { id: '1', type: 'media' },
54
+ { id: '3', type: 'layout' },
55
+ ];
56
+
57
+ const report = await analyzer.analyze(requiredFiles);
58
+
59
+ expect(report.files.required).toBe(2);
60
+ expect(report.files.orphaned).toBe(1);
61
+ expect(report.files.total).toBe(3);
62
+ expect(report.orphaned).toHaveLength(1);
63
+ expect(report.orphaned[0].id).toBe('2');
64
+ expect(report.orphanedSize).toBe(2000);
65
+ });
66
+
67
+ it('should report zero orphaned when all files are required', async () => {
68
+ mockCache._addFile({ id: '1', type: 'media', size: 1000, cachedAt: 100 });
69
+ mockCache._addFile({ id: '2', type: 'media', size: 2000, cachedAt: 200 });
70
+
71
+ const requiredFiles = [
72
+ { id: '1', type: 'media' },
73
+ { id: '2', type: 'media' },
74
+ ];
75
+
76
+ const report = await analyzer.analyze(requiredFiles);
77
+
78
+ expect(report.files.required).toBe(2);
79
+ expect(report.files.orphaned).toBe(0);
80
+ expect(report.orphaned).toHaveLength(0);
81
+ expect(report.orphanedSize).toBe(0);
82
+ expect(report.evicted).toHaveLength(0);
83
+ });
84
+
85
+ it('should handle empty cache', async () => {
86
+ const report = await analyzer.analyze([{ id: '1', type: 'media' }]);
87
+
88
+ expect(report.files.required).toBe(0);
89
+ expect(report.files.orphaned).toBe(0);
90
+ expect(report.files.total).toBe(0);
91
+ });
92
+
93
+ it('should handle empty required files list', async () => {
94
+ mockCache._addFile({ id: '1', type: 'media', size: 5000, cachedAt: 100 });
95
+
96
+ const report = await analyzer.analyze([]);
97
+
98
+ expect(report.files.required).toBe(0);
99
+ expect(report.files.orphaned).toBe(1);
100
+ expect(report.orphanedSize).toBe(5000);
101
+ });
102
+
103
+ it('should sort orphaned files oldest first', async () => {
104
+ mockCache._addFile({ id: 'new', type: 'media', size: 100, cachedAt: 3000 });
105
+ mockCache._addFile({ id: 'old', type: 'media', size: 100, cachedAt: 1000 });
106
+ mockCache._addFile({ id: 'mid', type: 'media', size: 100, cachedAt: 2000 });
107
+
108
+ const report = await analyzer.analyze([]);
109
+
110
+ expect(report.orphaned[0].id).toBe('old');
111
+ expect(report.orphaned[1].id).toBe('mid');
112
+ expect(report.orphaned[2].id).toBe('new');
113
+ });
114
+
115
+ it('should compare IDs as strings for mixed types', async () => {
116
+ mockCache._addFile({ id: '42', type: 'media', size: 100, cachedAt: 100 });
117
+
118
+ // RequiredFiles may use numeric IDs
119
+ const report = await analyzer.analyze([{ id: 42, type: 'media' }]);
120
+
121
+ expect(report.files.required).toBe(1);
122
+ expect(report.files.orphaned).toBe(0);
123
+ });
124
+
125
+ it('should not evict when storage is under threshold', async () => {
126
+ mockCache._addFile({ id: '1', type: 'media', size: 100, cachedAt: 100 });
127
+
128
+ // Mock storage at 50% (under default 80% threshold)
129
+ vi.stubGlobal('navigator', {
130
+ storage: {
131
+ estimate: vi.fn(async () => ({ usage: 500, quota: 1000 })),
132
+ },
133
+ });
134
+
135
+ const report = await analyzer.analyze([]);
136
+
137
+ expect(report.storage.percent).toBe(50);
138
+ expect(report.evicted).toHaveLength(0);
139
+
140
+ vi.unstubAllGlobals();
141
+ });
142
+
143
+ it('should include storage info in report', async () => {
144
+ vi.stubGlobal('navigator', {
145
+ storage: {
146
+ estimate: vi.fn(async () => ({ usage: 2_000_000_000, quota: 5_000_000_000 })),
147
+ },
148
+ });
149
+
150
+ const report = await analyzer.analyze([]);
151
+
152
+ expect(report.storage.usage).toBe(2_000_000_000);
153
+ expect(report.storage.quota).toBe(5_000_000_000);
154
+ expect(report.storage.percent).toBe(40);
155
+ expect(report.threshold).toBe(80);
156
+
157
+ vi.unstubAllGlobals();
158
+ });
159
+
160
+ it('should handle missing storage API gracefully', async () => {
161
+ // No navigator.storage
162
+ vi.stubGlobal('navigator', {});
163
+
164
+ const report = await analyzer.analyze([]);
165
+
166
+ expect(report.storage.usage).toBe(0);
167
+ expect(report.storage.quota).toBe(Infinity);
168
+ expect(report.storage.percent).toBe(0);
169
+ expect(report.evicted).toHaveLength(0);
170
+
171
+ vi.unstubAllGlobals();
172
+ });
173
+
174
+ it('should handle files with missing size or cachedAt', async () => {
175
+ mockCache._addFile({ id: '1', type: 'media' }); // no size, no cachedAt
176
+
177
+ const report = await analyzer.analyze([]);
178
+
179
+ expect(report.files.orphaned).toBe(1);
180
+ expect(report.orphanedSize).toBe(0);
181
+ expect(report.orphaned[0].size).toBe(0);
182
+ expect(report.orphaned[0].cachedAt).toBe(0);
183
+ });
184
+ });
185
+
186
+ describe('eviction', () => {
187
+ let deletedIds;
188
+
189
+ beforeEach(() => {
190
+ deletedIds = [];
191
+
192
+ // Mock IndexedDB transaction for metadata deletion
193
+ const mockStore = {
194
+ delete: vi.fn((id) => {
195
+ deletedIds.push(id);
196
+ return { set onsuccess(fn) { fn(); }, set onerror(_) {} };
197
+ }),
198
+ };
199
+ mockCache.db = {
200
+ transaction: vi.fn(() => ({
201
+ objectStore: vi.fn(() => mockStore),
202
+ })),
203
+ };
204
+ });
205
+
206
+ it('should evict orphaned files when storage exceeds threshold', async () => {
207
+ mockCache._addFile({ id: 'old', type: 'media', size: 500, cachedAt: 1000 });
208
+ mockCache._addFile({ id: 'newer', type: 'media', size: 300, cachedAt: 2000 });
209
+ mockCache._addFile({ id: 'required', type: 'media', size: 1000, cachedAt: 500 });
210
+
211
+ // 90% usage — exceeds 80% threshold
212
+ vi.stubGlobal('navigator', {
213
+ storage: {
214
+ estimate: vi.fn(async () => ({ usage: 9000, quota: 10000 })),
215
+ },
216
+ });
217
+
218
+ const report = await analyzer.analyze([{ id: 'required', type: 'media' }]);
219
+
220
+ expect(report.evicted.length).toBeGreaterThan(0);
221
+ // Should evict oldest first
222
+ expect(report.evicted[0].id).toBe('old');
223
+ expect(deletedIds).toContain('old');
224
+ // Cache API delete should also be called
225
+ expect(mockCache.cache.delete).toHaveBeenCalled();
226
+
227
+ vi.unstubAllGlobals();
228
+ });
229
+
230
+ it('should stop evicting once enough space is freed', async () => {
231
+ // 3 orphaned files, but only need to free a small amount
232
+ mockCache._addFile({ id: 'a', type: 'media', size: 2000, cachedAt: 1000 });
233
+ mockCache._addFile({ id: 'b', type: 'media', size: 2000, cachedAt: 2000 });
234
+ mockCache._addFile({ id: 'c', type: 'media', size: 2000, cachedAt: 3000 });
235
+
236
+ // 85% usage, threshold 80% → need to free 5% of 10000 = 500 bytes
237
+ // First file (2000 bytes) should be enough
238
+ vi.stubGlobal('navigator', {
239
+ storage: {
240
+ estimate: vi.fn(async () => ({ usage: 8500, quota: 10000 })),
241
+ },
242
+ });
243
+
244
+ const report = await analyzer.analyze([]);
245
+
246
+ expect(report.evicted).toHaveLength(1);
247
+ expect(report.evicted[0].id).toBe('a'); // oldest
248
+
249
+ vi.unstubAllGlobals();
250
+ });
251
+
252
+ it('should never evict required files', async () => {
253
+ mockCache._addFile({ id: 'keep', type: 'media', size: 5000, cachedAt: 100 });
254
+ mockCache._addFile({ id: 'orphan', type: 'media', size: 100, cachedAt: 200 });
255
+
256
+ vi.stubGlobal('navigator', {
257
+ storage: {
258
+ estimate: vi.fn(async () => ({ usage: 9500, quota: 10000 })),
259
+ },
260
+ });
261
+
262
+ const report = await analyzer.analyze([{ id: 'keep', type: 'media' }]);
263
+
264
+ // Only the orphan can be evicted, even though 'keep' is older and larger
265
+ const evictedIds = report.evicted.map(f => f.id);
266
+ expect(evictedIds).not.toContain('keep');
267
+
268
+ vi.unstubAllGlobals();
269
+ });
270
+
271
+ it('should respect custom threshold', async () => {
272
+ analyzer = new CacheAnalyzer(mockCache, { threshold: 50 });
273
+
274
+ mockCache._addFile({ id: '1', type: 'media', size: 100, cachedAt: 100 });
275
+
276
+ // 60% usage — under 80% but over 50%
277
+ vi.stubGlobal('navigator', {
278
+ storage: {
279
+ estimate: vi.fn(async () => ({ usage: 6000, quota: 10000 })),
280
+ },
281
+ });
282
+
283
+ const report = await analyzer.analyze([]);
284
+
285
+ expect(report.threshold).toBe(50);
286
+ expect(report.evicted.length).toBeGreaterThan(0);
287
+
288
+ vi.unstubAllGlobals();
289
+ });
290
+ });
291
+ });
package/src/cache.js CHANGED
@@ -405,9 +405,9 @@ export class CacheManager {
405
405
  }
406
406
 
407
407
  // Rewrite absolute CMS signed URLs to local cache paths
408
- // Matches: https://cms/xmds.php?file=bundle.min.js&...&X-Amz-Signature=...
408
+ // Matches: https://cms/xmds.php?file=... or https://cms/pwa/file?file=...
409
409
  // These absolute URLs bypass the <base> tag entirely, causing slow CMS fetches
410
- const cmsUrlRegex = /https?:\/\/[^"'\s)]+xmds\.php\?[^"'\s)]*file=([^&"'\s)]+)[^"'\s)]*/g;
410
+ const cmsUrlRegex = /https?:\/\/[^"'\s)]+(?:xmds\.php|pwa\/file)\?[^"'\s)]*file=([^&"'\s)]+)[^"'\s)]*/g;
411
411
  const staticResources = [];
412
412
  modifiedHtml = modifiedHtml.replace(cmsUrlRegex, (match, filename) => {
413
413
  const localPath = `${BASE}/cache/static/${filename}`;
package/src/index.js CHANGED
@@ -4,3 +4,4 @@ export const VERSION = pkg.version;
4
4
  export { CacheManager, cacheManager } from './cache.js';
5
5
  export { CacheProxy } from './cache-proxy.js';
6
6
  export { DownloadManager, FileDownload, LayoutTaskBuilder, isUrlExpired } from './download-manager.js';
7
+ export { CacheAnalyzer } from './cache-analyzer.js';