@xiboplayer/cache 0.6.3 → 0.6.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/README.md CHANGED
@@ -1,22 +1,57 @@
1
1
  # @xiboplayer/cache
2
2
 
3
- **Offline caching and download management with durable filesystem storage.**
3
+ **Offline caching and download management with parallel chunk downloads, resume support, and storage health monitoring.**
4
4
 
5
5
  ## Overview
6
6
 
7
- Manages media downloads and offline storage for Xibo players:
7
+ Manages the complete lifecycle of media files from CMS to local storage:
8
8
 
9
- - **StoreClient** pure REST client for reading/writing content in the ContentStore (filesystem via proxy)
10
- - **DownloadClient** Service Worker postMessage client for managing background downloads
11
- - **Parallel chunk downloads** large files (100MB+) split into configurable chunks, downloaded concurrently
12
- - **Header+trailer first** MP4 moov atom fetched first for instant playback start before full download
13
- - **MD5 verification** integrity checking with CRC32-based skip optimization
14
- - **Download queue** flat queue with barriers for layout-ordered downloading
15
- - **CacheAnalyzer** — stale media detection and storage-pressure eviction
16
- - **Widget data via enriched RequiredFiles** — RSS/dataset widget data is fetched through server-side enriched RequiredFiles paths (CMS adds download URLs), not via client-side pre-fetching
17
- - **Dynamic BASE path** — widget HTML `<base>` tag uses a dynamic path within the Service Worker scope for correct relative URL resolution
9
+ - **REST-based file store** -- StoreClient provides CRUD operations (has, get, put, remove, list) via proxy endpoints
10
+ - **Flat-queue download orchestration** -- DownloadManager with per-file and global concurrency limits
11
+ - **Intelligent chunking** -- files > 100MB split into 50MB chunks with prioritized headers and trailers for fast video start
12
+ - **Download resume** -- partial downloads resume automatically; expired URLs defer to next collection cycle
13
+ - **Stale media detection** -- CacheAnalyzer identifies orphaned files and evicts when storage exceeds threshold
14
+ - **Widget HTML preprocessing** -- base tag injection, dependency rewriting for iframe sandboxing
18
15
 
19
- No Cache API is used anywhere. All content is stored on the filesystem via the proxy's ContentStore.
16
+ No Cache API is used. All content is stored on the filesystem via the proxy's ContentStore.
17
+
18
+ ## Architecture
19
+
20
+ ```
21
+ CMS (RequiredFiles: size, URL, MD5)
22
+ |
23
+ v
24
+ +-------------------------------------+
25
+ | DownloadManager (Facade) |
26
+ | +- enqueue(fileInfo) |
27
+ | +- prioritizeLayoutFiles(mediaIds) |
28
+ | +- urgentChunk(type, id, idx) |
29
+ +-------------------------------------+
30
+ |
31
+ v
32
+ DownloadQueue (Flat)
33
+ +-----------------------------+
34
+ | [Task, Task, BARRIER, |
35
+ | Task, Task, Task] |
36
+ | |
37
+ | Global concurrency: 6 |
38
+ | Per-file chunks: 2-3 |
39
+ +-----------------------------+
40
+ |
41
+ +-> DownloadTask (chunk-0, high priority)
42
+ +-> DownloadTask (chunk-last, high priority)
43
+ +-> BARRIER (gate: waits for above to finish)
44
+ +-> DownloadTask (bulk chunks, normal priority)
45
+ |
46
+ v
47
+ HTTP Range GET
48
+ |
49
+ v
50
+ ContentStore (proxy)
51
+ /store/media/{id}
52
+ /store/layout/{id}
53
+ /store/widget/{L}/{R}/{M}
54
+ ```
20
55
 
21
56
  ## Installation
22
57
 
@@ -26,35 +61,176 @@ npm install @xiboplayer/cache
26
61
 
27
62
  ## Usage
28
63
 
64
+ ### StoreClient -- direct REST access to ContentStore
65
+
29
66
  ```javascript
30
- import { StoreClient, DownloadClient } from '@xiboplayer/cache';
67
+ import { StoreClient } from '@xiboplayer/cache';
31
68
 
32
- // Storage operations (pure REST, no SW needed)
33
69
  const store = new StoreClient();
34
- const { exists, size } = await store.has('media', '123');
35
- const blob = await store.get('media', '123');
36
- await store.put('widget', '472/221/190', htmlBlob, 'text/html');
37
70
 
38
- // Download management (SW postMessage)
39
- const downloads = new DownloadClient();
40
- await downloads.init();
41
- await downloads.download(files);
42
- const progress = await downloads.getProgress();
71
+ // Check existence
72
+ const exists = await store.has('media', '123');
73
+
74
+ // Retrieve file
75
+ const blob = await store.get('media', '456');
76
+
77
+ // Store widget HTML
78
+ const html = new Blob(['<h1>Widget</h1>'], { type: 'text/html' });
79
+ await store.put('widget', 'layout/1/region/2/media/3', html, 'text/html');
80
+
81
+ // List all cached files
82
+ const files = await store.list();
83
+
84
+ // Delete orphaned files
85
+ await store.remove([
86
+ { type: 'media', id: '999' },
87
+ { type: 'media', id: '1000' },
88
+ ]);
89
+ ```
90
+
91
+ ### DownloadManager -- orchestrated downloads
92
+
93
+ ```javascript
94
+ import { DownloadManager } from '@xiboplayer/cache';
95
+
96
+ const dm = new DownloadManager({
97
+ concurrency: 6,
98
+ chunkSize: 50 * 1024 * 1024,
99
+ chunksPerFile: 2,
100
+ });
101
+
102
+ // Enqueue a file
103
+ const media = dm.enqueue({
104
+ id: '123',
105
+ type: 'media',
106
+ path: 'https://cdn.example.com/video.mp4',
107
+ size: 250 * 1024 * 1024,
108
+ md5: 'abc123def456',
109
+ });
110
+
111
+ // Wait for completion
112
+ const blob = await media.wait();
113
+
114
+ // Get progress
115
+ const progress = dm.getProgress();
116
+ // { 'media/123': { downloaded: 50M, total: 250M, percent: '20.0', state: 'downloading' } }
117
+ ```
118
+
119
+ ### Prioritization and emergency chunks
120
+
121
+ ```javascript
122
+ // Boost files needed by current layout
123
+ dm.prioritizeLayoutFiles(['123', '456']);
124
+
125
+ // Emergency: chunk needed for video playback is stalled
126
+ // Moves to front of queue, reduces global concurrency to 2
127
+ dm.urgentChunk('media', '789', 3);
128
+ ```
129
+
130
+ ### CacheAnalyzer -- storage health
131
+
132
+ ```javascript
133
+ import { CacheAnalyzer } from '@xiboplayer/cache';
134
+
135
+ const analyzer = new CacheAnalyzer(store, { threshold: 80 });
136
+
137
+ const report = await analyzer.analyze(requiredFiles);
138
+ console.log(`Storage: ${report.storage.percent}%`);
139
+ console.log(`Orphaned: ${report.files.orphaned} files`);
140
+ console.log(`Evicted: ${report.evicted.length} files`);
141
+ ```
142
+
143
+ ## Download Pipeline
144
+
145
+ Files flow through stages:
146
+
147
+ 1. **Enqueueing** -- deduplication via `type/id` key, URL refresh if new URL has longer expiry
148
+ 2. **Preparation** -- HEAD request determines chunking (> 100MB = chunked)
149
+ 3. **Task creation** -- chunk-0 and chunk-last get high priority (video headers/trailers); BARRIER separates from bulk chunks
150
+ 4. **Processing** -- concurrency-aware: 6 global connections, 2-3 per file
151
+ 5. **Execution** -- HTTP Range GET with retries and exponential backoff
152
+ 6. **Storage** -- proxy ContentStore saves chunks to filesystem
153
+ 7. **Assembly** -- chunks concatenated; progressive callback enables playback before full download
154
+
155
+ ### Chunk strategy
156
+
157
+ - **Default chunk size:** 50MB
158
+ - **Threshold:** files > 100MB get chunked
159
+ - **Header+trailer first:** MP4 moov atom in chunk-0 and chunk-last allows instant playback start
160
+ - **Barriers:** critical chunks download before bulk chunks begin
161
+ - **Resume:** cached chunks tracked in `skipChunks` Set; only missing chunks downloaded
162
+ - **Expired URLs:** deferred (not failed) -- next collection provides fresh URL
163
+
164
+ ### Retry strategy by type
165
+
166
+ | Type | Max retries | Backoff | Notes |
167
+ |------|------------|---------|-------|
168
+ | media | 3 | 500ms | Large, cacheable files |
169
+ | layout | 3 | 500ms | Layout XML, stable URL |
170
+ | dataset | 4 | 15s, 30s, 60s, 120s | Re-enqueues 5x for "cache not ready" |
171
+ | static | 3 | 500ms | Widget dependencies (CSS, fonts) |
172
+
173
+ ## API Reference
174
+
175
+ ### StoreClient
176
+
177
+ | Method | Signature | Returns | Description |
178
+ |--------|-----------|---------|-------------|
179
+ | `has()` | `has(type, id)` | `Promise<boolean>` | Check if file exists |
180
+ | `get()` | `get(type, id)` | `Promise<Blob \| null>` | Retrieve file as Blob |
181
+ | `put()` | `put(type, id, body, contentType?)` | `Promise<boolean>` | Store file |
182
+ | `remove()` | `remove(files)` | `Promise<{deleted, total}>` | Delete files |
183
+ | `list()` | `list()` | `Promise<Array>` | List all cached files |
184
+
185
+ ### DownloadManager
186
+
187
+ ```javascript
188
+ new DownloadManager({ concurrency?, chunkSize?, chunksPerFile? })
189
+ ```
190
+
191
+ | Method | Signature | Returns | Description |
192
+ |--------|-----------|---------|-------------|
193
+ | `enqueue()` | `enqueue(fileInfo)` | `FileDownload` | Add file to download queue |
194
+ | `getTask()` | `getTask(key)` | `FileDownload \| null` | Get task by "type/id" key |
195
+ | `getProgress()` | `getProgress()` | `Record<string, Progress>` | All in-progress downloads |
196
+ | `prioritizeLayoutFiles()` | `prioritizeLayoutFiles(mediaIds)` | `void` | Boost priority for layout files |
197
+ | `urgentChunk()` | `urgentChunk(type, id, chunkIndex)` | `boolean` | Move chunk to front, reduce concurrency |
198
+ | `clear()` | `clear()` | `void` | Cancel all, clear queue |
199
+
200
+ ### CacheAnalyzer
201
+
202
+ ```javascript
203
+ new CacheAnalyzer(storeClient, { threshold: 80 })
204
+ ```
205
+
206
+ | Method | Signature | Returns | Description |
207
+ |--------|-----------|---------|-------------|
208
+ | `analyze()` | `analyze(requiredFiles)` | `Promise<Report>` | Compare cache vs required, evict if needed |
209
+
210
+ **Report:**
211
+ ```javascript
212
+ {
213
+ storage: { usage, quota, percent },
214
+ files: { required, orphaned, total },
215
+ orphaned: [{ id, type, size, cachedAt }],
216
+ evicted: [{ id, type, size }],
217
+ threshold: 80
218
+ }
43
219
  ```
44
220
 
45
- ## Exports
221
+ ### CacheManager
46
222
 
47
- | Export | Description |
48
- |--------|-------------|
49
- | `StoreClient` | Pure REST client for ContentStore has/get/put/remove/list |
50
- | `DownloadClient` | SW postMessage client for background downloads |
51
- | `DownloadManager` | Core download queue with barrier-based ordering |
52
- | `CacheAnalyzer` | Stale media detection and eviction |
223
+ | Method | Signature | Returns | Description |
224
+ |--------|-----------|---------|-------------|
225
+ | `getCacheKey()` | `getCacheKey(type, id)` | `string` | Get cache key path |
226
+ | `addDependant()` | `addDependant(mediaId, layoutId)` | `void` | Track layout -> media reference |
227
+ | `removeLayoutDependants()` | `removeLayoutDependants(layoutId)` | `string[]` | Remove layout, return orphaned media IDs |
228
+ | `isMediaReferenced()` | `isMediaReferenced(mediaId)` | `boolean` | Check if media is still used |
53
229
 
54
230
  ## Dependencies
55
231
 
56
- - `@xiboplayer/utils` logger, events
57
- - `spark-md5` MD5 hashing for file verification
232
+ - `@xiboplayer/utils` -- logger
233
+ - `spark-md5` -- MD5 hashing for file verification
58
234
 
59
235
  ---
60
236
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/cache",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Offline caching and download management with parallel chunk downloads",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "spark-md5": "^3.0.2",
19
- "@xiboplayer/utils": "0.6.3"
19
+ "@xiboplayer/utils": "0.6.5"
20
20
  },
21
21
  "devDependencies": {
22
22
  "vitest": "^2.0.0",
@@ -7,7 +7,7 @@
7
7
  * - CSS object-position fix for CMS template alignment
8
8
  *
9
9
  * URL rewriting is no longer needed — the CMS serves CSS with relative paths
10
- * (/api/v2/player/dependencies/font.otf), and the <base> tag resolves widget
10
+ * (${PLAYER_API}/dependencies/font.otf), and the <base> tag resolves widget
11
11
  * media references via mirror routes. Zero translation, zero regex.
12
12
  *
13
13
  * Runs on the main thread (needs window.location for URL construction).
@@ -25,7 +25,7 @@ const BASE = (typeof window !== 'undefined')
25
25
 
26
26
  /**
27
27
  * Store widget HTML in ContentStore for iframe loading.
28
- * Stored at mirror path /api/v2/player/widgets/{L}/{R}/{M} — same URL the
28
+ * Stored at mirror path ${PLAYER_API}/widgets/{L}/{R}/{M} — same URL the
29
29
  * CMS serves from, so iframes load directly from Express mirror routes.
30
30
  *
31
31
  * @param {string} layoutId - Layout ID
@@ -63,12 +63,13 @@ export async function cacheWidgetHtml(layoutId, regionId, mediaId, html) {
63
63
  }
64
64
 
65
65
  // Rewrite dependency URLs to local mirror paths. CMS sends absolute URLs
66
- // like https://cms.example.com/api/v2/player/dependencies/bundle.min.js
67
- // which fail due to CORS/auth. Replace with local /api/v2/player/dependencies/...
68
- modifiedHtml = modifiedHtml.replace(
69
- /https?:\/\/[^"'\s)]+?(\/api\/v2\/player\/dependencies\/[^"'\s?)]+)(\?[^"'\s)]*)?/g,
70
- (_, path) => path
66
+ // like https://cms.example.com${PLAYER_API}/dependencies/bundle.min.js
67
+ // which fail due to CORS/auth. Replace with local PLAYER_API/dependencies/...
68
+ const depsPattern = new RegExp(
69
+ `https?://[^"'\\s)]+?(${PLAYER_API.replace(/\//g, '\\/')}/dependencies/[^"'\\s?)]+)(\\?[^"'\\s)]*)?`,
70
+ 'g'
71
71
  );
72
+ modifiedHtml = modifiedHtml.replace(depsPattern, (_, path) => path);
72
73
 
73
74
  // Inject xiboICTargetId — XIC library reads this global before its IIFE runs
74
75
  // to set _lib.targetId, which is included in every IC HTTP request as {id: ...}
@@ -8,9 +8,9 @@
8
8
 
9
9
  import { describe, it, expect, beforeEach, vi } from 'vitest';
10
10
  import { cacheWidgetHtml } from './widget-html.js';
11
+ import { PLAYER_API } from '@xiboplayer/utils';
11
12
 
12
13
  const CMS_BASE = 'https://displays.superpantalles.com';
13
- const SIGNED_PARAMS = 'displayId=152&type=P&itemId=1&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260222T000000Z&X-Amz-Expires=1771803983&X-Amz-SignedHeaders=host&X-Amz-Signature=abc123';
14
14
 
15
15
  /**
16
16
  * RSS ticker widget HTML (layout 472, region 223, widget 193).
@@ -22,8 +22,8 @@ function makeRssTickerHtml() {
22
22
  <head>
23
23
  <meta charset="utf-8">
24
24
  <title>RSS Ticker</title>
25
- <script src="/api/v2/player/dependencies/bundle.min.js"></script>
26
- <link rel="stylesheet" href="/api/v2/player/dependencies/fonts.css">
25
+ <script src="${PLAYER_API}/dependencies/bundle.min.js"></script>
26
+ <link rel="stylesheet" href="${PLAYER_API}/dependencies/fonts.css">
27
27
  <style>.rss-item { padding: 10px; }</style>
28
28
  </head>
29
29
  <body>
@@ -47,8 +47,8 @@ function makePdfWidgetHtml() {
47
47
  <html>
48
48
  <head>
49
49
  <meta charset="utf-8">
50
- <script src="/api/v2/player/dependencies/bundle.min.js"></script>
51
- <link rel="stylesheet" href="/api/v2/player/dependencies/fonts.css"></link>
50
+ <script src="${PLAYER_API}/dependencies/bundle.min.js"></script>
51
+ <link rel="stylesheet" href="${PLAYER_API}/dependencies/fonts.css"></link>
52
52
  </head>
53
53
  <body>
54
54
  <object data="11.pdf" type="application/pdf" width="100%" height="100%"></object>
@@ -86,9 +86,11 @@ describe('cacheWidgetHtml', () => {
86
86
  global.fetch = createFetchMock();
87
87
  });
88
88
 
89
+ const storePrefix = `/store${PLAYER_API}/widgets/`;
90
+
89
91
  function getStoredWidget() {
90
92
  for (const [key, value] of storeContents) {
91
- if (key.startsWith('/store/api/v2/player/widgets/')) return value;
93
+ if (key.startsWith(storePrefix)) return value;
92
94
  }
93
95
  return undefined;
94
96
  }
@@ -101,7 +103,7 @@ describe('cacheWidgetHtml', () => {
101
103
  await cacheWidgetHtml('472', '223', '193', html);
102
104
 
103
105
  const stored = getStoredWidget();
104
- expect(stored).toContain('<base href="/api/v2/player/media/file/">');
106
+ expect(stored).toContain(`<base href="${PLAYER_API}/media/file/">`);
105
107
  });
106
108
 
107
109
  it('injects <base> tag when no <head> tag exists', async () => {
@@ -109,7 +111,7 @@ describe('cacheWidgetHtml', () => {
109
111
  await cacheWidgetHtml('472', '223', '193', html);
110
112
 
111
113
  const stored = getStoredWidget();
112
- expect(stored).toContain('<base href="/api/v2/player/media/file/">');
114
+ expect(stored).toContain(`<base href="${PLAYER_API}/media/file/">`);
113
115
  });
114
116
  });
115
117
 
@@ -120,8 +122,8 @@ describe('cacheWidgetHtml', () => {
120
122
  await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
121
123
 
122
124
  const stored = getStoredWidget();
123
- expect(stored).toContain('/api/v2/player/dependencies/bundle.min.js');
124
- expect(stored).toContain('/api/v2/player/dependencies/fonts.css');
125
+ expect(stored).toContain(`${PLAYER_API}/dependencies/bundle.min.js`);
126
+ expect(stored).toContain(`${PLAYER_API}/dependencies/fonts.css`);
125
127
  });
126
128
 
127
129
  it('preserves the data URL (193.json) for resolution via base tag', async () => {
@@ -143,7 +145,7 @@ describe('cacheWidgetHtml', () => {
143
145
  await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
144
146
 
145
147
  // Only the widget HTML PUT should be fetched — no proactive resource fetches
146
- const putCalls = fetchedUrls.filter(u => u.startsWith('/store/api/v2/player/widgets/'));
148
+ const putCalls = fetchedUrls.filter(u => u.startsWith(storePrefix));
147
149
  expect(putCalls.length).toBe(1);
148
150
  });
149
151
 
@@ -151,7 +153,7 @@ describe('cacheWidgetHtml', () => {
151
153
  await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
152
154
 
153
155
  const keys = [...storeContents.keys()];
154
- expect(keys.some(k => k.includes('/store/api/v2/player/widgets/472/223/193'))).toBe(true);
156
+ expect(keys.some(k => k.includes(`${storePrefix}472/223/193`))).toBe(true);
155
157
  });
156
158
  });
157
159
 
@@ -169,7 +171,7 @@ describe('cacheWidgetHtml', () => {
169
171
  await cacheWidgetHtml('472', '221', '190', makePdfWidgetHtml());
170
172
 
171
173
  const keys = [...storeContents.keys()];
172
- expect(keys.some(k => k.includes('/store/api/v2/player/widgets/472/221/190'))).toBe(true);
174
+ expect(keys.some(k => k.includes(`${storePrefix}472/221/190`))).toBe(true);
173
175
  });
174
176
  });
175
177
 
@@ -257,8 +259,8 @@ describe('cacheWidgetHtml', () => {
257
259
  await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
258
260
 
259
261
  const keys = [...storeContents.keys()];
260
- expect(keys.some(k => k.includes('/store/api/v2/player/widgets/472/221/190'))).toBe(true);
261
- expect(keys.some(k => k.includes('/store/api/v2/player/widgets/472/223/193'))).toBe(true);
262
+ expect(keys.some(k => k.includes(`${storePrefix}472/221/190`))).toBe(true);
263
+ expect(keys.some(k => k.includes(`${storePrefix}472/223/193`))).toBe(true);
262
264
  });
263
265
  });
264
266
  });