@hashtree/worker 0.1.1 → 0.1.2
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/dist/capabilities/blossomTransport.d.ts.map +1 -1
- package/dist/capabilities/blossomTransport.js +41 -8
- package/dist/capabilities/blossomTransport.js.map +1 -1
- package/dist/htree-path.d.ts +13 -0
- package/dist/htree-path.d.ts.map +1 -0
- package/dist/htree-path.js +38 -0
- package/dist/htree-path.js.map +1 -0
- package/dist/iris/mediaHandler.d.ts +25 -1
- package/dist/iris/mediaHandler.d.ts.map +1 -1
- package/dist/iris/mediaHandler.js +838 -102
- package/dist/iris/mediaHandler.js.map +1 -1
- package/dist/iris/ndk.d.ts +2 -1
- package/dist/iris/ndk.d.ts.map +1 -1
- package/dist/iris/ndk.js +12 -6
- package/dist/iris/ndk.js.map +1 -1
- package/dist/iris/protocol.d.ts +3 -0
- package/dist/iris/protocol.d.ts.map +1 -1
- package/dist/iris/protocol.js.map +1 -1
- package/dist/iris/publicAssetUrl.d.ts +6 -0
- package/dist/iris/publicAssetUrl.d.ts.map +1 -0
- package/dist/iris/publicAssetUrl.js +14 -0
- package/dist/iris/publicAssetUrl.js.map +1 -0
- package/dist/iris/rootPathResolver.d.ts +9 -0
- package/dist/iris/rootPathResolver.d.ts.map +1 -0
- package/dist/iris/rootPathResolver.js +32 -0
- package/dist/iris/rootPathResolver.js.map +1 -0
- package/dist/iris/treeRootCache.d.ts +9 -1
- package/dist/iris/treeRootCache.d.ts.map +1 -1
- package/dist/iris/treeRootCache.js +57 -2
- package/dist/iris/treeRootCache.js.map +1 -1
- package/dist/iris/treeRootSubscription.d.ts +5 -0
- package/dist/iris/treeRootSubscription.d.ts.map +1 -1
- package/dist/iris/treeRootSubscription.js +279 -15
- package/dist/iris/treeRootSubscription.js.map +1 -1
- package/dist/iris/worker.js +12 -27
- package/dist/iris/worker.js.map +1 -1
- package/dist/p2p/signaling.d.ts.map +1 -1
- package/dist/p2p/signaling.js +31 -4
- package/dist/p2p/signaling.js.map +1 -1
- package/dist/p2p/webrtcController.d.ts +21 -4
- package/dist/p2p/webrtcController.d.ts.map +1 -1
- package/dist/p2p/webrtcController.js +199 -59
- package/dist/p2p/webrtcController.js.map +1 -1
- package/package.json +12 -3
|
@@ -5,20 +5,73 @@
|
|
|
5
5
|
* Handles media requests from the service worker via MessagePort.
|
|
6
6
|
* Supports both direct CID-based requests and path-based requests with live streaming.
|
|
7
7
|
*/
|
|
8
|
-
import { getCachedRoot } from './treeRootCache';
|
|
9
|
-
import { subscribeToTreeRoots } from './treeRootSubscription';
|
|
8
|
+
import { getCachedRoot, onCachedRootUpdate } from './treeRootCache';
|
|
9
|
+
import { resolveTreeRootNow, subscribeToTreeRoots } from './treeRootSubscription';
|
|
10
10
|
import { getErrorMessage } from './utils/errorMessage';
|
|
11
|
-
import { nhashDecode } from '@hashtree/core';
|
|
11
|
+
import { nhashDecode, toHex } from '@hashtree/core';
|
|
12
12
|
import { nip19 } from 'nostr-tools';
|
|
13
|
+
import { LRUCache } from './utils/lruCache';
|
|
13
14
|
// Thumbnail filename patterns to look for (in priority order)
|
|
14
15
|
const THUMBNAIL_PATTERNS = ['thumbnail.jpg', 'thumbnail.webp', 'thumbnail.png', 'thumbnail.jpeg'];
|
|
16
|
+
const PLAYABLE_MEDIA_EXTENSION_SET = new Set([
|
|
17
|
+
'.mp4',
|
|
18
|
+
'.webm',
|
|
19
|
+
'.mkv',
|
|
20
|
+
'.mov',
|
|
21
|
+
'.avi',
|
|
22
|
+
'.m4v',
|
|
23
|
+
'.ogv',
|
|
24
|
+
'.3gp',
|
|
25
|
+
'.mp3',
|
|
26
|
+
'.wav',
|
|
27
|
+
'.flac',
|
|
28
|
+
'.m4a',
|
|
29
|
+
'.aac',
|
|
30
|
+
'.ogg',
|
|
31
|
+
'.oga',
|
|
32
|
+
'.opus',
|
|
33
|
+
]);
|
|
34
|
+
const MIME_TYPES = {
|
|
35
|
+
mp4: 'video/mp4',
|
|
36
|
+
m4v: 'video/mp4',
|
|
37
|
+
webm: 'video/webm',
|
|
38
|
+
ogg: 'video/ogg',
|
|
39
|
+
ogv: 'video/ogg',
|
|
40
|
+
mov: 'video/quicktime',
|
|
41
|
+
avi: 'video/x-msvideo',
|
|
42
|
+
mkv: 'video/x-matroska',
|
|
43
|
+
mp3: 'audio/mpeg',
|
|
44
|
+
wav: 'audio/wav',
|
|
45
|
+
flac: 'audio/flac',
|
|
46
|
+
m4a: 'audio/mp4',
|
|
47
|
+
aac: 'audio/aac',
|
|
48
|
+
oga: 'audio/ogg',
|
|
49
|
+
opus: 'audio/ogg',
|
|
50
|
+
jpg: 'image/jpeg',
|
|
51
|
+
jpeg: 'image/jpeg',
|
|
52
|
+
png: 'image/png',
|
|
53
|
+
webp: 'image/webp',
|
|
54
|
+
};
|
|
15
55
|
// Timeout for considering a stream "done" (no updates)
|
|
16
56
|
const LIVE_STREAM_TIMEOUT = 10000; // 10 seconds
|
|
17
57
|
const ROOT_WAIT_TIMEOUT_MS = 15000;
|
|
18
|
-
const
|
|
58
|
+
const DIRECTORY_PROBE_TIMEOUT_MS = 1000;
|
|
59
|
+
const IMMUTABLE_LOOKUP_CACHE_HIT_TTL_MS = 5 * 60 * 1000;
|
|
60
|
+
const IMMUTABLE_LOOKUP_CACHE_MISS_TTL_MS = 1000;
|
|
61
|
+
const DIRECTORY_CACHE_SIZE = 1024;
|
|
62
|
+
const RESOLVED_ENTRY_CACHE_SIZE = 2048;
|
|
63
|
+
const MUTABLE_RESOLVED_ENTRY_CACHE_SIZE = 2048;
|
|
64
|
+
const FILE_SIZE_CACHE_SIZE = 1024;
|
|
65
|
+
const THUMBNAIL_PATH_CACHE_SIZE = 1024;
|
|
19
66
|
// Chunk size for streaming to media port
|
|
20
67
|
const MEDIA_CHUNK_SIZE = 256 * 1024; // 256KB chunks - matches videoChunker's firstChunkSize
|
|
21
68
|
const activeMediaStreams = new Map();
|
|
69
|
+
const inflightRootWaits = new Map();
|
|
70
|
+
const directoryLookupCache = createAsyncLookupCache(DIRECTORY_CACHE_SIZE);
|
|
71
|
+
const resolvedEntryLookupCache = createAsyncLookupCache(RESOLVED_ENTRY_CACHE_SIZE);
|
|
72
|
+
const mutableResolvedEntryLookupCache = createAsyncLookupCache(MUTABLE_RESOLVED_ENTRY_CACHE_SIZE);
|
|
73
|
+
const fileSizeLookupCache = createAsyncLookupCache(FILE_SIZE_CACHE_SIZE);
|
|
74
|
+
const thumbnailPathLookupCache = createAsyncLookupCache(THUMBNAIL_PATH_CACHE_SIZE);
|
|
22
75
|
let mediaPort = null;
|
|
23
76
|
let tree = null;
|
|
24
77
|
let mediaDebugEnabled = false;
|
|
@@ -32,11 +85,73 @@ function logMediaDebug(event, data) {
|
|
|
32
85
|
console.log(`[WorkerMedia] ${event}`);
|
|
33
86
|
}
|
|
34
87
|
}
|
|
88
|
+
function createAsyncLookupCache(maxEntries) {
|
|
89
|
+
return {
|
|
90
|
+
values: new LRUCache(maxEntries),
|
|
91
|
+
inflight: new Map(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function clearAsyncLookupCache(cache) {
|
|
95
|
+
cache.values.clear();
|
|
96
|
+
cache.inflight.clear();
|
|
97
|
+
}
|
|
98
|
+
function clearMediaLookupCaches() {
|
|
99
|
+
inflightRootWaits.clear();
|
|
100
|
+
clearAsyncLookupCache(directoryLookupCache);
|
|
101
|
+
clearAsyncLookupCache(resolvedEntryLookupCache);
|
|
102
|
+
clearAsyncLookupCache(mutableResolvedEntryLookupCache);
|
|
103
|
+
clearAsyncLookupCache(fileSizeLookupCache);
|
|
104
|
+
clearAsyncLookupCache(thumbnailPathLookupCache);
|
|
105
|
+
}
|
|
106
|
+
function getLookupTtlMs(value) {
|
|
107
|
+
return value == null ? IMMUTABLE_LOOKUP_CACHE_MISS_TTL_MS : IMMUTABLE_LOOKUP_CACHE_HIT_TTL_MS;
|
|
108
|
+
}
|
|
109
|
+
async function loadCachedLookup(cache, key, loader, ttlMsForValue) {
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
const cached = cache.values.get(key);
|
|
112
|
+
if (cached && cached.expiresAt > now) {
|
|
113
|
+
return cached.value;
|
|
114
|
+
}
|
|
115
|
+
if (cached) {
|
|
116
|
+
cache.values.delete(key);
|
|
117
|
+
}
|
|
118
|
+
const inflight = cache.inflight.get(key);
|
|
119
|
+
if (inflight) {
|
|
120
|
+
return inflight;
|
|
121
|
+
}
|
|
122
|
+
const pending = loader()
|
|
123
|
+
.then((value) => {
|
|
124
|
+
const ttlMs = ttlMsForValue(value);
|
|
125
|
+
if (ttlMs > 0) {
|
|
126
|
+
cache.values.set(key, {
|
|
127
|
+
value,
|
|
128
|
+
expiresAt: Date.now() + ttlMs,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return value;
|
|
132
|
+
})
|
|
133
|
+
.finally(() => {
|
|
134
|
+
cache.inflight.delete(key);
|
|
135
|
+
});
|
|
136
|
+
cache.inflight.set(key, pending);
|
|
137
|
+
return pending;
|
|
138
|
+
}
|
|
139
|
+
function cidCacheKey(cid) {
|
|
140
|
+
return cid.key ? `${toHex(cid.hash)}?k=${toHex(cid.key)}` : toHex(cid.hash);
|
|
141
|
+
}
|
|
142
|
+
function sameCid(a, b) {
|
|
143
|
+
if (!a && !b)
|
|
144
|
+
return true;
|
|
145
|
+
if (!a || !b)
|
|
146
|
+
return false;
|
|
147
|
+
return cidCacheKey(a) === cidCacheKey(b);
|
|
148
|
+
}
|
|
35
149
|
/**
|
|
36
150
|
* Initialize the media handler with the HashTree instance
|
|
37
151
|
*/
|
|
38
152
|
export function initMediaHandler(hashTree) {
|
|
39
153
|
tree = hashTree;
|
|
154
|
+
clearMediaLookupCaches();
|
|
40
155
|
}
|
|
41
156
|
/**
|
|
42
157
|
* Register a MessagePort from the service worker for media streaming
|
|
@@ -44,6 +159,7 @@ export function initMediaHandler(hashTree) {
|
|
|
44
159
|
export function registerMediaPort(port, debug) {
|
|
45
160
|
mediaPort = port;
|
|
46
161
|
mediaDebugEnabled = !!debug;
|
|
162
|
+
port.start?.();
|
|
47
163
|
port.onmessage = async (e) => {
|
|
48
164
|
const req = e.data;
|
|
49
165
|
if (req.type === 'hashtree-file') {
|
|
@@ -137,7 +253,10 @@ async function handleMediaRequestByPath(req) {
|
|
|
137
253
|
}
|
|
138
254
|
// Navigate to file within tree if path specified
|
|
139
255
|
if (filePath) {
|
|
140
|
-
const resolved = await
|
|
256
|
+
const resolved = await resolveMutableTreeEntry(npub, treeName, filePath, {
|
|
257
|
+
allowSingleSegmentRootFallback: false,
|
|
258
|
+
expectedMimeType: mimeType,
|
|
259
|
+
});
|
|
141
260
|
if (!resolved) {
|
|
142
261
|
mediaPort.postMessage({
|
|
143
262
|
type: 'error',
|
|
@@ -231,7 +350,7 @@ function watchTreeRootForStream(npub, treeName, filePath, streamInfo) {
|
|
|
231
350
|
// Navigate to file
|
|
232
351
|
let fileCid = cid;
|
|
233
352
|
if (filePath) {
|
|
234
|
-
const resolved = await
|
|
353
|
+
const resolved = await resolvePathFromDirectoryListings(cid, filePath);
|
|
235
354
|
if (!resolved) {
|
|
236
355
|
scheduleNext();
|
|
237
356
|
return;
|
|
@@ -275,7 +394,7 @@ function watchTreeRootForStream(npub, treeName, filePath, streamInfo) {
|
|
|
275
394
|
async function handleSwFileRequest(req) {
|
|
276
395
|
if (!tree || !mediaPort)
|
|
277
396
|
return;
|
|
278
|
-
const { requestId, npub, nhash, treeName, path, start, end, mimeType, download } = req;
|
|
397
|
+
const { requestId, npub, nhash, treeName, path, start, end, rangeHeader, mimeType, download } = req;
|
|
279
398
|
logMediaDebug('sw:request', {
|
|
280
399
|
requestId,
|
|
281
400
|
npub: npub ?? null,
|
|
@@ -284,81 +403,80 @@ async function handleSwFileRequest(req) {
|
|
|
284
403
|
path,
|
|
285
404
|
start,
|
|
286
405
|
end: end ?? null,
|
|
406
|
+
rangeHeader: rangeHeader ?? null,
|
|
287
407
|
mimeType,
|
|
288
408
|
download: !!download,
|
|
289
409
|
});
|
|
290
410
|
try {
|
|
291
|
-
let
|
|
411
|
+
let resolvedEntry = null;
|
|
292
412
|
if (nhash) {
|
|
293
413
|
// Direct nhash request - decode to CID
|
|
294
414
|
const rootCid = nhashDecode(nhash);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
cid = entry.cid;
|
|
304
|
-
}
|
|
305
|
-
else if (path && !path.includes('/')) {
|
|
306
|
-
// Try to resolve as file within directory first
|
|
307
|
-
const entry = await tree.resolvePath(rootCid, path);
|
|
308
|
-
cid = entry ? entry.cid : rootCid;
|
|
309
|
-
}
|
|
310
|
-
else {
|
|
311
|
-
cid = rootCid;
|
|
415
|
+
resolvedEntry = await resolveEntryWithinRoot(rootCid, path || '', {
|
|
416
|
+
allowSingleSegmentRootFallback: true,
|
|
417
|
+
expectedMimeType: mimeType,
|
|
418
|
+
});
|
|
419
|
+
if (!resolvedEntry) {
|
|
420
|
+
sendSwError(requestId, 404, `File not found: ${path}`);
|
|
421
|
+
return;
|
|
312
422
|
}
|
|
313
423
|
}
|
|
314
424
|
else if (npub && treeName) {
|
|
315
|
-
// Npub-based request - resolve through
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
425
|
+
// Npub-based request - resolve through mutable root history when needed
|
|
426
|
+
resolvedEntry = await resolveMutableTreeEntry(npub, treeName, path || '', {
|
|
427
|
+
allowSingleSegmentRootFallback: false,
|
|
428
|
+
expectedMimeType: mimeType,
|
|
429
|
+
});
|
|
430
|
+
if (!resolvedEntry) {
|
|
431
|
+
sendSwError(requestId, 404, 'File not found');
|
|
319
432
|
return;
|
|
320
433
|
}
|
|
321
|
-
// Handle thumbnail requests without extension
|
|
322
|
-
let resolvedPath = path || '';
|
|
323
|
-
if (resolvedPath.endsWith('/thumbnail') || resolvedPath === 'thumbnail') {
|
|
324
|
-
const dirPath = resolvedPath.endsWith('/thumbnail')
|
|
325
|
-
? resolvedPath.slice(0, -'/thumbnail'.length)
|
|
326
|
-
: '';
|
|
327
|
-
const actualPath = await findThumbnailInDir(rootCid, dirPath);
|
|
328
|
-
if (actualPath) {
|
|
329
|
-
resolvedPath = actualPath;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
// Navigate to file
|
|
333
|
-
if (resolvedPath) {
|
|
334
|
-
const entry = await tree.resolvePath(rootCid, resolvedPath);
|
|
335
|
-
if (!entry) {
|
|
336
|
-
sendSwError(requestId, 404, 'File not found');
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
cid = entry.cid;
|
|
340
|
-
}
|
|
341
|
-
else {
|
|
342
|
-
cid = rootCid;
|
|
343
|
-
}
|
|
344
434
|
}
|
|
345
|
-
if (!cid) {
|
|
435
|
+
if (!resolvedEntry?.cid) {
|
|
346
436
|
sendSwError(requestId, 400, 'Invalid request');
|
|
347
437
|
return;
|
|
348
438
|
}
|
|
349
439
|
// Get file size
|
|
350
|
-
|
|
440
|
+
// Directory listings may report 0 for non-empty files when the actual byte size
|
|
441
|
+
// is not embedded in the tree node metadata. Treat that as unknown and resolve
|
|
442
|
+
// the real size from the file itself before building HTTP headers.
|
|
443
|
+
const knownSize = typeof resolvedEntry.size === 'number' && resolvedEntry.size > 0
|
|
444
|
+
? resolvedEntry.size
|
|
445
|
+
: null;
|
|
446
|
+
const effectivePath = resolvedEntry.path ?? path;
|
|
447
|
+
const effectiveMimeType = (mimeType === 'application/octet-stream'
|
|
448
|
+
|| isThumbnailAliasPath(path)
|
|
449
|
+
|| isVideoAliasPath(path))
|
|
450
|
+
? guessMimeTypeFromPath(effectivePath)
|
|
451
|
+
: mimeType;
|
|
452
|
+
const totalSize = knownSize ?? await getFileSize(resolvedEntry.cid);
|
|
351
453
|
if (totalSize === null) {
|
|
352
|
-
|
|
454
|
+
const canBufferWholeFile = !rangeHeader && start === 0 && end === undefined;
|
|
455
|
+
if (!canBufferWholeFile || typeof tree.readFile !== 'function') {
|
|
456
|
+
sendSwError(requestId, 404, 'File data not found');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const fullData = await tree.readFile(resolvedEntry.cid);
|
|
460
|
+
if (!fullData) {
|
|
461
|
+
sendSwError(requestId, 404, 'File data not found');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
sendBufferedSwResponse(requestId, fullData, {
|
|
465
|
+
npub,
|
|
466
|
+
path: effectivePath,
|
|
467
|
+
mimeType: effectiveMimeType,
|
|
468
|
+
download,
|
|
469
|
+
});
|
|
353
470
|
return;
|
|
354
471
|
}
|
|
355
472
|
// Stream the content
|
|
356
|
-
await streamSwResponse(requestId, cid, totalSize, {
|
|
473
|
+
await streamSwResponse(requestId, resolvedEntry.cid, totalSize, {
|
|
357
474
|
npub,
|
|
358
|
-
path,
|
|
475
|
+
path: effectivePath,
|
|
359
476
|
start,
|
|
360
477
|
end,
|
|
361
|
-
|
|
478
|
+
rangeHeader,
|
|
479
|
+
mimeType: effectiveMimeType,
|
|
362
480
|
download,
|
|
363
481
|
});
|
|
364
482
|
}
|
|
@@ -367,22 +485,72 @@ async function handleSwFileRequest(req) {
|
|
|
367
485
|
}
|
|
368
486
|
}
|
|
369
487
|
async function waitForCachedRoot(npub, treeName) {
|
|
370
|
-
|
|
488
|
+
const cached = await getCachedRoot(npub, treeName);
|
|
371
489
|
if (cached)
|
|
372
490
|
return cached;
|
|
491
|
+
const cacheKey = `${npub}/${treeName}`;
|
|
492
|
+
const inflight = inflightRootWaits.get(cacheKey);
|
|
493
|
+
if (inflight) {
|
|
494
|
+
return inflight;
|
|
495
|
+
}
|
|
373
496
|
const pubkey = decodeNpubToPubkey(npub);
|
|
374
497
|
if (pubkey) {
|
|
375
498
|
subscribeToTreeRoots(pubkey);
|
|
376
499
|
}
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
500
|
+
const pending = new Promise((resolve) => {
|
|
501
|
+
let settled = false;
|
|
502
|
+
const finish = (cid) => {
|
|
503
|
+
if (settled)
|
|
504
|
+
return;
|
|
505
|
+
settled = true;
|
|
506
|
+
clearTimeout(timeout);
|
|
507
|
+
unsubscribe();
|
|
508
|
+
resolve(cid);
|
|
509
|
+
};
|
|
510
|
+
const unsubscribe = onCachedRootUpdate((updatedNpub, updatedTreeName, cid) => {
|
|
511
|
+
if (updatedNpub === npub && updatedTreeName === treeName && cid) {
|
|
512
|
+
finish(cid);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
const timeout = setTimeout(() => {
|
|
516
|
+
logMediaDebug('root:timeout', { npub, treeName });
|
|
517
|
+
finish(null);
|
|
518
|
+
}, ROOT_WAIT_TIMEOUT_MS);
|
|
519
|
+
void getCachedRoot(npub, treeName).then((current) => {
|
|
520
|
+
if (current) {
|
|
521
|
+
finish(current);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
void resolveTreeRootNow(npub, treeName, ROOT_WAIT_TIMEOUT_MS).then((resolved) => {
|
|
525
|
+
if (resolved) {
|
|
526
|
+
finish(resolved);
|
|
527
|
+
}
|
|
528
|
+
}).catch(() => { });
|
|
529
|
+
}).finally(() => {
|
|
530
|
+
inflightRootWaits.delete(cacheKey);
|
|
531
|
+
});
|
|
532
|
+
inflightRootWaits.set(cacheKey, pending);
|
|
533
|
+
return pending;
|
|
534
|
+
}
|
|
535
|
+
async function resolveMutableTreeEntry(npub, treeName, path, options) {
|
|
536
|
+
const currentRoot = await waitForCachedRoot(npub, treeName);
|
|
537
|
+
const cacheKey = [
|
|
538
|
+
npub,
|
|
539
|
+
treeName,
|
|
540
|
+
currentRoot ? cidCacheKey(currentRoot) : 'none',
|
|
541
|
+
options?.allowSingleSegmentRootFallback ? 'root-fallback' : 'strict',
|
|
542
|
+
options?.expectedMimeType ?? '',
|
|
543
|
+
path,
|
|
544
|
+
].join('|');
|
|
545
|
+
return loadCachedLookup(mutableResolvedEntryLookupCache, cacheKey, async () => {
|
|
546
|
+
if (currentRoot) {
|
|
547
|
+
const currentEntry = await resolveEntryWithinRoot(currentRoot, path, options);
|
|
548
|
+
if (currentEntry) {
|
|
549
|
+
return currentEntry;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return null;
|
|
553
|
+
}, (value) => getLookupTtlMs(value));
|
|
386
554
|
}
|
|
387
555
|
function decodeNpubToPubkey(npub) {
|
|
388
556
|
if (!npub.startsWith('npub1'))
|
|
@@ -397,6 +565,405 @@ function decodeNpubToPubkey(npub) {
|
|
|
397
565
|
return null;
|
|
398
566
|
}
|
|
399
567
|
}
|
|
568
|
+
async function resolveEntryWithinRoot(rootCid, path, options) {
|
|
569
|
+
if (!tree)
|
|
570
|
+
return null;
|
|
571
|
+
const cacheKey = [
|
|
572
|
+
cidCacheKey(rootCid),
|
|
573
|
+
options?.allowSingleSegmentRootFallback ? 'root-fallback' : 'strict',
|
|
574
|
+
path,
|
|
575
|
+
].join('|');
|
|
576
|
+
return loadCachedLookup(resolvedEntryLookupCache, cacheKey, async () => {
|
|
577
|
+
if (!path) {
|
|
578
|
+
return { cid: rootCid };
|
|
579
|
+
}
|
|
580
|
+
const resolvedThumbnail = await resolveThumbnailAliasEntry(rootCid, path);
|
|
581
|
+
if (resolvedThumbnail) {
|
|
582
|
+
return resolvedThumbnail;
|
|
583
|
+
}
|
|
584
|
+
if (isThumbnailAliasPath(path)) {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
const resolvedPlayable = await resolvePlayableAliasEntry(rootCid, path);
|
|
588
|
+
if (resolvedPlayable) {
|
|
589
|
+
return resolvedPlayable;
|
|
590
|
+
}
|
|
591
|
+
if (isVideoAliasPath(path)) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
if (options?.allowSingleSegmentRootFallback &&
|
|
595
|
+
await canFallbackToRootBlob(rootCid, path, path, options?.expectedMimeType)) {
|
|
596
|
+
const isDirectory = await canListDirectory(rootCid);
|
|
597
|
+
if (!isDirectory) {
|
|
598
|
+
return { cid: rootCid };
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (options?.allowSingleSegmentRootFallback &&
|
|
602
|
+
!path.includes('/') &&
|
|
603
|
+
isExactThumbnailFilenamePath(path) &&
|
|
604
|
+
!(await canListDirectory(rootCid))) {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
const entry = await resolvePathFromDirectoryListings(rootCid, path);
|
|
608
|
+
if (entry) {
|
|
609
|
+
return entry;
|
|
610
|
+
}
|
|
611
|
+
if (options?.allowSingleSegmentRootFallback &&
|
|
612
|
+
await canFallbackToRootBlob(rootCid, path, path, options?.expectedMimeType)) {
|
|
613
|
+
return { cid: rootCid };
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}, (value) => getLookupTtlMs(value));
|
|
617
|
+
}
|
|
618
|
+
async function resolveCidWithinRoot(rootCid, path, options) {
|
|
619
|
+
return (await resolveEntryWithinRoot(rootCid, path, options))?.cid ?? null;
|
|
620
|
+
}
|
|
621
|
+
async function resolvePathFromDirectoryListings(rootCid, path) {
|
|
622
|
+
if (!tree)
|
|
623
|
+
return null;
|
|
624
|
+
const parts = path.split('/').filter(Boolean);
|
|
625
|
+
if (parts.length === 0) {
|
|
626
|
+
return { cid: rootCid };
|
|
627
|
+
}
|
|
628
|
+
let currentCid = rootCid;
|
|
629
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
630
|
+
const entries = await loadDirectoryListing(currentCid);
|
|
631
|
+
if (!entries) {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
const entry = entries.find((candidate) => candidate.name === parts[i]);
|
|
635
|
+
if (!entry?.cid) {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
if (i === parts.length - 1) {
|
|
639
|
+
return { cid: entry.cid, size: entry.size, path: parts.slice(0, i + 1).join('/') };
|
|
640
|
+
}
|
|
641
|
+
currentCid = entry.cid;
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
function hasImageBlobSignature(blob) {
|
|
646
|
+
if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) {
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
if (blob.length >= 8
|
|
650
|
+
&& blob[0] === 0x89
|
|
651
|
+
&& blob[1] === 0x50
|
|
652
|
+
&& blob[2] === 0x4e
|
|
653
|
+
&& blob[3] === 0x47
|
|
654
|
+
&& blob[4] === 0x0d
|
|
655
|
+
&& blob[5] === 0x0a
|
|
656
|
+
&& blob[6] === 0x1a
|
|
657
|
+
&& blob[7] === 0x0a) {
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
if (blob.length >= 12
|
|
661
|
+
&& blob[0] === 0x52
|
|
662
|
+
&& blob[1] === 0x49
|
|
663
|
+
&& blob[2] === 0x46
|
|
664
|
+
&& blob[3] === 0x46
|
|
665
|
+
&& blob[8] === 0x57
|
|
666
|
+
&& blob[9] === 0x45
|
|
667
|
+
&& blob[10] === 0x42
|
|
668
|
+
&& blob[11] === 0x50) {
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
if (blob.length >= 6
|
|
672
|
+
&& blob[0] === 0x47
|
|
673
|
+
&& blob[1] === 0x49
|
|
674
|
+
&& blob[2] === 0x46
|
|
675
|
+
&& blob[3] === 0x38
|
|
676
|
+
&& (blob[4] === 0x37 || blob[4] === 0x39)
|
|
677
|
+
&& blob[5] === 0x61) {
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
function readAscii(blob, start, end) {
|
|
683
|
+
return String.fromCharCode(...blob.slice(start, end));
|
|
684
|
+
}
|
|
685
|
+
function sniffPlayableMediaExtension(blob) {
|
|
686
|
+
if (!blob.length)
|
|
687
|
+
return null;
|
|
688
|
+
if (blob.length >= 12 && readAscii(blob, 4, 8) === 'ftyp') {
|
|
689
|
+
const brand = readAscii(blob, 8, 12).toLowerCase();
|
|
690
|
+
if (brand.startsWith('m4a'))
|
|
691
|
+
return '.m4a';
|
|
692
|
+
if (brand.startsWith('qt'))
|
|
693
|
+
return '.mov';
|
|
694
|
+
return '.mp4';
|
|
695
|
+
}
|
|
696
|
+
if (blob.length >= 4
|
|
697
|
+
&& blob[0] === 0x1a
|
|
698
|
+
&& blob[1] === 0x45
|
|
699
|
+
&& blob[2] === 0xdf
|
|
700
|
+
&& blob[3] === 0xa3) {
|
|
701
|
+
const lowerHeader = readAscii(blob, 0, Math.min(blob.length, 64)).toLowerCase();
|
|
702
|
+
return lowerHeader.includes('webm') ? '.webm' : '.mkv';
|
|
703
|
+
}
|
|
704
|
+
if (blob.length >= 4 && readAscii(blob, 0, 4) === 'OggS') {
|
|
705
|
+
return '.ogg';
|
|
706
|
+
}
|
|
707
|
+
if (blob.length >= 4 && readAscii(blob, 0, 4) === 'fLaC') {
|
|
708
|
+
return '.flac';
|
|
709
|
+
}
|
|
710
|
+
if (blob.length >= 12
|
|
711
|
+
&& readAscii(blob, 0, 4) === 'RIFF'
|
|
712
|
+
&& readAscii(blob, 8, 12) === 'WAVE') {
|
|
713
|
+
return '.wav';
|
|
714
|
+
}
|
|
715
|
+
if (blob.length >= 3 && readAscii(blob, 0, 3) === 'ID3') {
|
|
716
|
+
return '.mp3';
|
|
717
|
+
}
|
|
718
|
+
if (blob.length >= 2 && blob[0] === 0xff && (blob[1] & 0xf6) === 0xf0) {
|
|
719
|
+
return '.aac';
|
|
720
|
+
}
|
|
721
|
+
if (blob.length >= 2 && blob[0] === 0xff && (blob[1] & 0xe0) === 0xe0) {
|
|
722
|
+
return '.mp3';
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
function guessMimeTypeFromPath(path) {
|
|
727
|
+
const ext = path?.split('.').pop()?.toLowerCase() ?? '';
|
|
728
|
+
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
729
|
+
}
|
|
730
|
+
async function canFallbackToRootBlob(rootCid, resolvedPath, originalPath, expectedMimeType) {
|
|
731
|
+
if (resolvedPath !== originalPath)
|
|
732
|
+
return false;
|
|
733
|
+
if (resolvedPath.includes('/'))
|
|
734
|
+
return false;
|
|
735
|
+
if (isExactThumbnailFilenamePath(resolvedPath)) {
|
|
736
|
+
if (!expectedMimeType?.startsWith('image/')) {
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
if (!tree) {
|
|
740
|
+
return false;
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
if (typeof tree.readFileRange === 'function') {
|
|
744
|
+
const header = await tree.readFileRange(rootCid, 0, 64);
|
|
745
|
+
return !!header && hasImageBlobSignature(header);
|
|
746
|
+
}
|
|
747
|
+
if (typeof tree.getBlob === 'function') {
|
|
748
|
+
const blob = await tree.getBlob(rootCid.hash);
|
|
749
|
+
return !!blob && hasImageBlobSignature(blob);
|
|
750
|
+
}
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return /\.[A-Za-z0-9]{1,16}$/.test(resolvedPath);
|
|
758
|
+
}
|
|
759
|
+
function isThumbnailAliasPath(path) {
|
|
760
|
+
return path === 'thumbnail' || path.endsWith('/thumbnail');
|
|
761
|
+
}
|
|
762
|
+
function isVideoAliasPath(path) {
|
|
763
|
+
return path === 'video' || path.endsWith('/video');
|
|
764
|
+
}
|
|
765
|
+
function isExactThumbnailFilenamePath(path) {
|
|
766
|
+
const fileName = path.split('/').filter(Boolean).at(-1)?.toLowerCase() ?? '';
|
|
767
|
+
return fileName.startsWith('thumbnail.');
|
|
768
|
+
}
|
|
769
|
+
function isImageFileName(fileName) {
|
|
770
|
+
const normalized = fileName.trim().toLowerCase();
|
|
771
|
+
return normalized.endsWith('.jpg')
|
|
772
|
+
|| normalized.endsWith('.jpeg')
|
|
773
|
+
|| normalized.endsWith('.png')
|
|
774
|
+
|| normalized.endsWith('.webp');
|
|
775
|
+
}
|
|
776
|
+
function findThumbnailFileEntry(entries) {
|
|
777
|
+
for (const pattern of THUMBNAIL_PATTERNS) {
|
|
778
|
+
const directMatch = entries.find((entry) => entry.name === pattern && entry.cid);
|
|
779
|
+
if (directMatch?.cid) {
|
|
780
|
+
return directMatch;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return entries.find((entry) => isImageFileName(entry.name) && entry.cid) ?? null;
|
|
784
|
+
}
|
|
785
|
+
function resolveEmbeddedThumbnailLookup(value, entries, dirPath) {
|
|
786
|
+
if (typeof value !== 'string') {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
const trimmed = value.trim();
|
|
790
|
+
if (!trimmed) {
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
if (trimmed.startsWith('nhash1')) {
|
|
794
|
+
try {
|
|
795
|
+
return {
|
|
796
|
+
path: dirPath ? `${dirPath}/thumbnail` : 'thumbnail',
|
|
797
|
+
cid: nhashDecode(trimmed),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const normalized = trimmed
|
|
805
|
+
.split('?')[0]
|
|
806
|
+
?.split('#')[0]
|
|
807
|
+
?.split('/')
|
|
808
|
+
.filter(Boolean)
|
|
809
|
+
.at(-1);
|
|
810
|
+
if (!normalized) {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
const entry = entries.find((candidate) => candidate.name === normalized && candidate.cid);
|
|
814
|
+
if (!entry?.cid) {
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
path: dirPath ? `${dirPath}/${entry.name}` : entry.name,
|
|
819
|
+
cid: entry.cid,
|
|
820
|
+
size: entry.size,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
async function resolveThumbnailLookupFromMetadata(entries, dirPath) {
|
|
824
|
+
if (!tree) {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
const playableEntry = entries.find((entry) => isPlayableMediaFileName(entry.name));
|
|
828
|
+
const playableThumbnail = playableEntry?.meta && typeof playableEntry.meta.thumbnail === 'string'
|
|
829
|
+
? playableEntry.meta.thumbnail
|
|
830
|
+
: null;
|
|
831
|
+
const embeddedPlayableThumbnail = resolveEmbeddedThumbnailLookup(playableThumbnail, entries, dirPath);
|
|
832
|
+
if (embeddedPlayableThumbnail) {
|
|
833
|
+
return embeddedPlayableThumbnail;
|
|
834
|
+
}
|
|
835
|
+
for (const metadataName of ['metadata.json', 'info.json']) {
|
|
836
|
+
const metadataEntry = entries.find((entry) => entry.name === metadataName && entry.cid);
|
|
837
|
+
if (!metadataEntry?.cid) {
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
const metadataData = await tree.readFile(metadataEntry.cid);
|
|
842
|
+
if (!metadataData) {
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const parsed = JSON.parse(new TextDecoder().decode(metadataData));
|
|
846
|
+
const embeddedThumbnail = resolveEmbeddedThumbnailLookup(parsed?.thumbnail, entries, dirPath);
|
|
847
|
+
if (embeddedThumbnail) {
|
|
848
|
+
return embeddedThumbnail;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
async function findThumbnailLookupInEntries(entries, dirPath) {
|
|
858
|
+
const directThumbnail = findThumbnailFileEntry(entries);
|
|
859
|
+
if (directThumbnail?.cid) {
|
|
860
|
+
return {
|
|
861
|
+
path: dirPath ? `${dirPath}/${directThumbnail.name}` : directThumbnail.name,
|
|
862
|
+
cid: directThumbnail.cid,
|
|
863
|
+
size: directThumbnail.size,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
return await resolveThumbnailLookupFromMetadata(entries, dirPath);
|
|
867
|
+
}
|
|
868
|
+
async function detectDirectPlayableLookup(cid, dirPath) {
|
|
869
|
+
if (!tree || typeof tree.readFileRange !== 'function') {
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
try {
|
|
873
|
+
const header = await tree.readFileRange(cid, 0, 64);
|
|
874
|
+
if (!(header instanceof Uint8Array) || header.length === 0) {
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
const extension = sniffPlayableMediaExtension(header);
|
|
878
|
+
if (!extension) {
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
const fileName = `video${extension}`;
|
|
882
|
+
return {
|
|
883
|
+
cid,
|
|
884
|
+
path: dirPath ? `${dirPath}/${fileName}` : fileName,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
function findPlayableLookupInEntries(entries, dirPath) {
|
|
892
|
+
const playableEntry = entries.find((entry) => isPlayableMediaFileName(entry.name) && entry.cid);
|
|
893
|
+
if (!playableEntry?.cid) {
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
cid: playableEntry.cid,
|
|
898
|
+
size: playableEntry.size,
|
|
899
|
+
path: dirPath ? `${dirPath}/${playableEntry.name}` : playableEntry.name,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
async function normalizeAliasPath(rootCid, path) {
|
|
903
|
+
if (!path)
|
|
904
|
+
return '';
|
|
905
|
+
const resolvedThumbnail = await resolveThumbnailAliasEntry(rootCid, path);
|
|
906
|
+
if (resolvedThumbnail) {
|
|
907
|
+
return resolvedThumbnail.path;
|
|
908
|
+
}
|
|
909
|
+
const resolvedPlayable = await resolvePlayableAliasEntry(rootCid, path);
|
|
910
|
+
if (resolvedPlayable?.path) {
|
|
911
|
+
return resolvedPlayable.path;
|
|
912
|
+
}
|
|
913
|
+
return path;
|
|
914
|
+
}
|
|
915
|
+
async function canListDirectory(rootCid) {
|
|
916
|
+
if (!tree)
|
|
917
|
+
return false;
|
|
918
|
+
try {
|
|
919
|
+
if ('isDirectory' in tree && typeof tree.isDirectory === 'function') {
|
|
920
|
+
return await tree.isDirectory(rootCid);
|
|
921
|
+
}
|
|
922
|
+
const entries = await listDirectoryWithProbeTimeout(rootCid);
|
|
923
|
+
return Array.isArray(entries);
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
export const __test__ = {
|
|
930
|
+
resolveCidWithinRoot,
|
|
931
|
+
resolveMutableTreeEntry,
|
|
932
|
+
normalizeAliasPath,
|
|
933
|
+
canListDirectory,
|
|
934
|
+
waitForCachedRoot,
|
|
935
|
+
};
|
|
936
|
+
async function resolveThumbnailAliasEntry(rootCid, path) {
|
|
937
|
+
if (!isThumbnailAliasPath(path)) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
const dirPath = path.endsWith('/thumbnail')
|
|
941
|
+
? path.slice(0, -'/thumbnail'.length)
|
|
942
|
+
: '';
|
|
943
|
+
return findThumbnailLookupInDir(rootCid, dirPath);
|
|
944
|
+
}
|
|
945
|
+
async function resolvePlayableAliasEntry(rootCid, path) {
|
|
946
|
+
if (!isVideoAliasPath(path)) {
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
const dirPath = path.endsWith('/video')
|
|
950
|
+
? path.slice(0, -'/video'.length)
|
|
951
|
+
: '';
|
|
952
|
+
return findPlayableLookupInDir(rootCid, dirPath);
|
|
953
|
+
}
|
|
954
|
+
async function loadDirectoryListing(cid) {
|
|
955
|
+
if (!tree)
|
|
956
|
+
return null;
|
|
957
|
+
return loadCachedLookup(directoryLookupCache, cidCacheKey(cid), async () => tree.listDirectory(cid), (value) => getLookupTtlMs(value));
|
|
958
|
+
}
|
|
959
|
+
async function listDirectoryWithProbeTimeout(cid) {
|
|
960
|
+
if (!tree)
|
|
961
|
+
return null;
|
|
962
|
+
return Promise.race([
|
|
963
|
+
loadDirectoryListing(cid),
|
|
964
|
+
new Promise((resolve) => setTimeout(() => resolve(null), DIRECTORY_PROBE_TIMEOUT_MS)),
|
|
965
|
+
]);
|
|
966
|
+
}
|
|
400
967
|
/**
|
|
401
968
|
* Send error response to SW
|
|
402
969
|
*/
|
|
@@ -417,46 +984,112 @@ function sendSwError(requestId, status, message) {
|
|
|
417
984
|
async function getFileSize(cid) {
|
|
418
985
|
if (!tree)
|
|
419
986
|
return null;
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
987
|
+
return loadCachedLookup(fileSizeLookupCache, cidCacheKey(cid), async () => {
|
|
988
|
+
const treeNode = await tree.getTreeNode(cid);
|
|
989
|
+
if (treeNode) {
|
|
990
|
+
// Chunked file - sum link sizes from decrypted tree node
|
|
991
|
+
return treeNode.links.reduce((sum, l) => sum + l.size, 0);
|
|
992
|
+
}
|
|
993
|
+
// Single blob - fetch to check existence and get size
|
|
994
|
+
const blob = await tree.getBlob(cid.hash);
|
|
995
|
+
if (!blob)
|
|
996
|
+
return null;
|
|
997
|
+
// For encrypted blobs, decrypted size = encrypted size - 16 (nonce overhead)
|
|
998
|
+
return cid.key ? Math.max(0, blob.length - 16) : blob.length;
|
|
999
|
+
}, (value) => getLookupTtlMs(value));
|
|
431
1000
|
}
|
|
432
1001
|
/**
|
|
433
1002
|
* Find actual thumbnail file in a directory
|
|
434
1003
|
*/
|
|
435
|
-
async function
|
|
1004
|
+
async function findThumbnailLookupInDir(rootCid, dirPath) {
|
|
436
1005
|
if (!tree)
|
|
437
1006
|
return null;
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
1007
|
+
return loadCachedLookup(thumbnailPathLookupCache, `${cidCacheKey(rootCid)}|${dirPath}`, async () => {
|
|
1008
|
+
try {
|
|
1009
|
+
const dirEntry = dirPath
|
|
1010
|
+
? await resolvePathFromDirectoryListings(rootCid, dirPath)
|
|
1011
|
+
: { cid: rootCid };
|
|
1012
|
+
if (!dirEntry)
|
|
1013
|
+
return null;
|
|
1014
|
+
const entries = await listDirectoryWithProbeTimeout(dirEntry.cid);
|
|
1015
|
+
if (!entries)
|
|
1016
|
+
return null;
|
|
1017
|
+
const rootThumbnail = await findThumbnailLookupInEntries(entries, dirPath);
|
|
1018
|
+
if (rootThumbnail) {
|
|
1019
|
+
return rootThumbnail;
|
|
1020
|
+
}
|
|
1021
|
+
const hasPlayableMediaFile = entries.some((entry) => isPlayableMediaFileName(entry.name));
|
|
1022
|
+
if (!hasPlayableMediaFile && entries.length > 0) {
|
|
1023
|
+
const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
1024
|
+
for (const entry of sortedEntries.slice(0, 3)) {
|
|
1025
|
+
if (entry.name.endsWith('.json') || entry.name.endsWith('.txt')) {
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
const subEntries = await listDirectoryWithProbeTimeout(entry.cid);
|
|
1030
|
+
if (!subEntries) {
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
const prefix = dirPath ? `${dirPath}/${entry.name}` : entry.name;
|
|
1034
|
+
const nestedThumbnail = await findThumbnailLookupInEntries(subEntries, prefix);
|
|
1035
|
+
if (nestedThumbnail) {
|
|
1036
|
+
return nestedThumbnail;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
444
1044
|
return null;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (!entries)
|
|
1045
|
+
}
|
|
1046
|
+
catch {
|
|
448
1047
|
return null;
|
|
449
|
-
// Find first matching thumbnail pattern
|
|
450
|
-
for (const pattern of THUMBNAIL_PATTERNS) {
|
|
451
|
-
if (entries.some(e => e.name === pattern)) {
|
|
452
|
-
return dirPath ? `${dirPath}/${pattern}` : pattern;
|
|
453
|
-
}
|
|
454
1048
|
}
|
|
1049
|
+
}, (value) => getLookupTtlMs(value));
|
|
1050
|
+
}
|
|
1051
|
+
async function findPlayableLookupInDir(rootCid, dirPath) {
|
|
1052
|
+
if (!tree)
|
|
455
1053
|
return null;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
1054
|
+
return loadCachedLookup(resolvedEntryLookupCache, `${cidCacheKey(rootCid)}|video|${dirPath}`, async () => {
|
|
1055
|
+
try {
|
|
1056
|
+
const dirEntry = dirPath
|
|
1057
|
+
? await resolvePathFromDirectoryListings(rootCid, dirPath)
|
|
1058
|
+
: { cid: rootCid, path: '' };
|
|
1059
|
+
if (!dirEntry)
|
|
1060
|
+
return null;
|
|
1061
|
+
const entries = await listDirectoryWithProbeTimeout(dirEntry.cid);
|
|
1062
|
+
if (entries && entries.length > 0) {
|
|
1063
|
+
const directPlayable = findPlayableLookupInEntries(entries, dirPath);
|
|
1064
|
+
if (directPlayable) {
|
|
1065
|
+
return directPlayable;
|
|
1066
|
+
}
|
|
1067
|
+
const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
1068
|
+
for (const entry of sortedEntries.slice(0, 3)) {
|
|
1069
|
+
if (entry.name.endsWith('.json') || entry.name.endsWith('.txt') || !entry.cid) {
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
const prefix = dirPath ? `${dirPath}/${entry.name}` : entry.name;
|
|
1073
|
+
const subEntries = await listDirectoryWithProbeTimeout(entry.cid);
|
|
1074
|
+
if (!subEntries || subEntries.length === 0) {
|
|
1075
|
+
const directPlayableChild = await detectDirectPlayableLookup(entry.cid, prefix);
|
|
1076
|
+
if (directPlayableChild) {
|
|
1077
|
+
return directPlayableChild;
|
|
1078
|
+
}
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
const nestedPlayable = findPlayableLookupInEntries(subEntries, prefix);
|
|
1082
|
+
if (nestedPlayable) {
|
|
1083
|
+
return nestedPlayable;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
return await detectDirectPlayableLookup(dirEntry.cid, dirPath);
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
}, (value) => getLookupTtlMs(value));
|
|
460
1093
|
}
|
|
461
1094
|
/**
|
|
462
1095
|
* Stream response to SW with proper HTTP headers
|
|
@@ -464,9 +1097,20 @@ async function findThumbnailInDir(rootCid, dirPath) {
|
|
|
464
1097
|
async function streamSwResponse(requestId, cid, totalSize, options) {
|
|
465
1098
|
if (!tree || !mediaPort)
|
|
466
1099
|
return;
|
|
467
|
-
const { npub, path, start = 0, end, mimeType = 'application/octet-stream', download } = options;
|
|
468
|
-
|
|
469
|
-
|
|
1100
|
+
const { npub, path, start = 0, end, rangeHeader, mimeType = 'application/octet-stream', download } = options;
|
|
1101
|
+
let rangeStart = start;
|
|
1102
|
+
let rangeEnd = end !== undefined ? Math.min(end, totalSize - 1) : totalSize - 1;
|
|
1103
|
+
if (rangeHeader) {
|
|
1104
|
+
const parsedRange = parseHttpByteRange(rangeHeader, totalSize);
|
|
1105
|
+
if (parsedRange.kind === 'range') {
|
|
1106
|
+
rangeStart = parsedRange.range.start;
|
|
1107
|
+
rangeEnd = parsedRange.range.endInclusive;
|
|
1108
|
+
}
|
|
1109
|
+
else if (parsedRange.kind === 'unsatisfiable') {
|
|
1110
|
+
sendSwError(requestId, 416, `Range not satisfiable for ${totalSize} byte file`);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
470
1114
|
const contentLength = rangeEnd - rangeStart + 1;
|
|
471
1115
|
// Build cache control header
|
|
472
1116
|
const isNpubRequest = !!npub;
|
|
@@ -493,7 +1137,7 @@ async function streamSwResponse(requestId, cid, totalSize, options) {
|
|
|
493
1137
|
headers['Content-Disposition'] = `attachment; filename="${filename}"`;
|
|
494
1138
|
}
|
|
495
1139
|
// Determine status (206 for range requests)
|
|
496
|
-
const isRangeRequest = end !== undefined || start > 0;
|
|
1140
|
+
const isRangeRequest = !!rangeHeader || end !== undefined || start > 0;
|
|
497
1141
|
const status = isRangeRequest ? 206 : 200;
|
|
498
1142
|
if (isRangeRequest) {
|
|
499
1143
|
headers['Content-Range'] = `bytes ${rangeStart}-${rangeEnd}/${totalSize}`;
|
|
@@ -526,4 +1170,96 @@ async function streamSwResponse(requestId, cid, totalSize, options) {
|
|
|
526
1170
|
// Signal done
|
|
527
1171
|
mediaPort.postMessage({ type: 'done', requestId });
|
|
528
1172
|
}
|
|
1173
|
+
function sendBufferedSwResponse(requestId, data, options) {
|
|
1174
|
+
if (!mediaPort)
|
|
1175
|
+
return;
|
|
1176
|
+
const { npub, path, mimeType = 'application/octet-stream', download } = options;
|
|
1177
|
+
const isNpubRequest = !!npub;
|
|
1178
|
+
const isImage = mimeType.startsWith('image/');
|
|
1179
|
+
let cacheControl;
|
|
1180
|
+
if (!isNpubRequest) {
|
|
1181
|
+
cacheControl = 'public, max-age=31536000, immutable';
|
|
1182
|
+
}
|
|
1183
|
+
else if (isImage) {
|
|
1184
|
+
cacheControl = 'public, max-age=60, stale-while-revalidate=86400';
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
cacheControl = 'no-cache, no-store, must-revalidate';
|
|
1188
|
+
}
|
|
1189
|
+
const headers = {
|
|
1190
|
+
'Content-Type': mimeType,
|
|
1191
|
+
'Content-Length': String(data.length),
|
|
1192
|
+
'Cache-Control': cacheControl,
|
|
1193
|
+
};
|
|
1194
|
+
if (download) {
|
|
1195
|
+
headers['Content-Disposition'] = `attachment; filename="${path || 'file'}"`;
|
|
1196
|
+
}
|
|
1197
|
+
mediaPort.postMessage({
|
|
1198
|
+
type: 'headers',
|
|
1199
|
+
requestId,
|
|
1200
|
+
status: 200,
|
|
1201
|
+
headers,
|
|
1202
|
+
totalSize: data.length,
|
|
1203
|
+
});
|
|
1204
|
+
mediaPort.postMessage({ type: 'chunk', requestId, data }, [data.buffer]);
|
|
1205
|
+
mediaPort.postMessage({ type: 'done', requestId });
|
|
1206
|
+
}
|
|
1207
|
+
function parseHttpByteRange(rangeHeader, totalSize) {
|
|
1208
|
+
if (!rangeHeader)
|
|
1209
|
+
return { kind: 'unsupported' };
|
|
1210
|
+
const bytesRange = rangeHeader.startsWith('bytes=')
|
|
1211
|
+
? rangeHeader.slice('bytes='.length)
|
|
1212
|
+
: null;
|
|
1213
|
+
if (!bytesRange || bytesRange.includes(','))
|
|
1214
|
+
return { kind: 'unsupported' };
|
|
1215
|
+
if (totalSize <= 0)
|
|
1216
|
+
return { kind: 'unsatisfiable' };
|
|
1217
|
+
const parts = bytesRange.split('-', 2);
|
|
1218
|
+
if (parts.length !== 2)
|
|
1219
|
+
return { kind: 'unsupported' };
|
|
1220
|
+
const [startPart, endPart] = parts;
|
|
1221
|
+
if (!startPart) {
|
|
1222
|
+
const suffixLength = Number.parseInt(endPart, 10);
|
|
1223
|
+
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
|
|
1224
|
+
return { kind: 'unsatisfiable' };
|
|
1225
|
+
}
|
|
1226
|
+
const clampedSuffix = Math.min(suffixLength, totalSize);
|
|
1227
|
+
return {
|
|
1228
|
+
kind: 'range',
|
|
1229
|
+
range: {
|
|
1230
|
+
start: totalSize - clampedSuffix,
|
|
1231
|
+
endInclusive: totalSize - 1,
|
|
1232
|
+
},
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
const start = Number.parseInt(startPart, 10);
|
|
1236
|
+
if (!Number.isFinite(start) || start < 0 || start >= totalSize) {
|
|
1237
|
+
return { kind: 'unsatisfiable' };
|
|
1238
|
+
}
|
|
1239
|
+
const endInclusive = endPart ? Number.parseInt(endPart, 10) : totalSize - 1;
|
|
1240
|
+
if (!Number.isFinite(endInclusive) || endInclusive < start) {
|
|
1241
|
+
return { kind: 'unsatisfiable' };
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
kind: 'range',
|
|
1245
|
+
range: {
|
|
1246
|
+
start,
|
|
1247
|
+
endInclusive: Math.min(endInclusive, totalSize - 1),
|
|
1248
|
+
},
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
function isPlayableMediaFileName(name) {
|
|
1252
|
+
const normalized = name.trim().toLowerCase();
|
|
1253
|
+
if (!normalized || normalized.endsWith('/')) {
|
|
1254
|
+
return false;
|
|
1255
|
+
}
|
|
1256
|
+
if (normalized.startsWith('video.')) {
|
|
1257
|
+
return true;
|
|
1258
|
+
}
|
|
1259
|
+
const lastDot = normalized.lastIndexOf('.');
|
|
1260
|
+
if (lastDot === -1) {
|
|
1261
|
+
return false;
|
|
1262
|
+
}
|
|
1263
|
+
return PLAYABLE_MEDIA_EXTENSION_SET.has(normalized.slice(lastDot));
|
|
1264
|
+
}
|
|
529
1265
|
//# sourceMappingURL=mediaHandler.js.map
|