sliftutils 1.4.18 → 1.4.20

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/index.d.ts CHANGED
@@ -912,6 +912,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
912
912
  streamFoldTriggerRows: number;
913
913
  streamFoldTriggerBytes: number;
914
914
  writeFlushMaxDelayMs: number;
915
+ fileSetPollIntervalMs: number;
915
916
  };
916
917
  export interface ReactiveDeps {
917
918
  observe(signal: string): void;
@@ -950,6 +951,9 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
950
951
  private streamTimes;
951
952
  private columnCache;
952
953
  private readerKeys;
954
+ private loadedFileSet;
955
+ private readerEpoch;
956
+ private fileSetPollTimer;
953
957
  private dataGen;
954
958
  private pendingSignals;
955
959
  private triggerTimer;
@@ -971,7 +975,11 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
971
975
  private syncSetup;
972
976
  private localTime;
973
977
  private applyRemote;
978
+ private clearReaderState;
974
979
  private resetReader;
980
+ private reloadReader;
981
+ private readWithReload;
982
+ private pollFileSet;
975
983
  write(entry: T): Promise<void>;
976
984
  writeBatch(entries: T[]): Promise<void>;
977
985
  delete(key: string): Promise<void>;
@@ -1396,6 +1404,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1396
1404
  write(value: Buffer): Promise<void>;
1397
1405
  close(): Promise<void>;
1398
1406
  }>;
1407
+ getURL?(): Promise<string>;
1399
1408
  };
1400
1409
  export type DirectoryWrapper = {
1401
1410
  readonly kind: "directory";
@@ -1428,6 +1437,7 @@ declare module "sliftutils/storage/FileFolderAPI" {
1428
1437
  arrayBuffer: () => Promise<ArrayBuffer>;
1429
1438
  };
1430
1439
  }>;
1440
+ getURL(): Promise<string>;
1431
1441
  createWritable(config?: {
1432
1442
  keepExistingData?: boolean;
1433
1443
  }): Promise<{
@@ -1492,6 +1502,8 @@ declare module "sliftutils/storage/FileFolderAPI" {
1492
1502
  isRemote?: boolean;
1493
1503
  };
1494
1504
  export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
1505
+ export declare function getFileURL(file: FileWrapper): Promise<string>;
1506
+ export declare function disposeFileURL(url: string): void;
1495
1507
  export declare function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage>;
1496
1508
  export declare function tryToLoadPointer(pointer: string): Promise<DirectoryWrapper | undefined>;
1497
1509
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.4.18",
3
+ "version": "1.4.20",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -8,6 +8,7 @@ export declare const bulkDatabase2Timing: {
8
8
  streamFoldTriggerRows: number;
9
9
  streamFoldTriggerBytes: number;
10
10
  writeFlushMaxDelayMs: number;
11
+ fileSetPollIntervalMs: number;
11
12
  };
12
13
  export interface ReactiveDeps {
13
14
  observe(signal: string): void;
@@ -46,6 +47,9 @@ export declare class BulkDatabaseBase<T extends {
46
47
  private streamTimes;
47
48
  private columnCache;
48
49
  private readerKeys;
50
+ private loadedFileSet;
51
+ private readerEpoch;
52
+ private fileSetPollTimer;
49
53
  private dataGen;
50
54
  private pendingSignals;
51
55
  private triggerTimer;
@@ -67,7 +71,11 @@ export declare class BulkDatabaseBase<T extends {
67
71
  private syncSetup;
68
72
  private localTime;
69
73
  private applyRemote;
74
+ private clearReaderState;
70
75
  private resetReader;
76
+ private reloadReader;
77
+ private readWithReload;
78
+ private pollFileSet;
71
79
  write(entry: T): Promise<void>;
72
80
  writeBatch(entries: T[]): Promise<void>;
73
81
  delete(key: string): Promise<void>;
@@ -45,6 +45,10 @@ const STALE_DELETE_MS = 24 * 60 * 60 * 1000;
45
45
  // -view bug), which a later reload resolves.
46
46
  const MAX_READ_ATTEMPTS = 8;
47
47
 
48
+ // A read that hits a file a concurrent merge deleted reloads the index and retries; if it keeps hitting
49
+ // missing files this many times it gives up and throws (so a genuinely-unreadable file can't loop forever).
50
+ const MAX_INDEX_RELOAD_ATTEMPTS = 3;
51
+
48
52
  // The first ("consolidate recent") merge accumulates the newest files up to this many bytes into one
49
53
  // file (half the target, so it has room to grow before it needs splitting). The key-stratified second
50
54
  // merge groups keys into runs of this many bytes and only rewrites a group whose fraction of duplicate
@@ -87,6 +91,9 @@ export const bulkDatabase2Timing = {
87
91
  // the default in Node, where append is real and cheap; the browser ramps to 15s to avoid rewriting
88
92
  // the whole stream file per write.
89
93
  writeFlushMaxDelayMs: isNode() ? 0 : 15 * 1000,
94
+ // We proactively re-list files this often; if the set changed under us (another tab/process merged) we
95
+ // reload the index, so reads pick up the change even without first hitting a missing-file error.
96
+ fileSetPollIntervalMs: 30 * 60 * 1000,
90
97
  };
91
98
 
92
99
  // Marks a key as deleted in the in-memory overlay.
@@ -236,6 +243,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
236
243
  }
237
244
  } catch { /* not in a DOM context */ }
238
245
  }
246
+ // Proactively notice another writer's merge (files added/removed) and reload the index, so reads
247
+ // stay correct even without first hitting a missing-file error.
248
+ this.fileSetPollTimer = setInterval(() => void this.pollFileSet(), bulkDatabase2Timing.fileSetPollIntervalMs);
249
+ (this.fileSetPollTimer as { unref?: () => void }).unref?.();
239
250
  }
240
251
 
241
252
  // ---- buffered stream writes (per collection) ----
@@ -305,6 +316,18 @@ export class BulkDatabaseBase<T extends { key: string }> {
305
316
  // update of an existing key (only the written columns change) from adding/removing a key (which appears
306
317
  // in / disappears from EVERY column). Set on reader build, cleared on reset.
307
318
  private readerKeys: Set<string> | undefined;
319
+
320
+ // ---- the on-disk index ----
321
+ // The "index" is the loaded reader (file headers + key columns joined into a resolved view) plus the
322
+ // set of files it was built from. A concurrent merge in another tab/process rewrites files out from
323
+ // under us; we notice either when a read hits a deleted file (readWithReload) or via the periodic poll
324
+ // (pollFileSet), and reload the index — cheap, since a build only reads headers + key columns.
325
+ private loadedFileSet: Set<string> | undefined; // file names the current reader was built from
326
+ // Bumped every time the index is cleared/reloaded. A failed read captures it before reading and only
327
+ // triggers a reload if it's unchanged afterward — so N concurrent failures coalesce onto ONE rebuild
328
+ // (the rest see the bumped epoch, skip their own reload, and just await the in-flight one).
329
+ private readerEpoch = 0;
330
+ private fileSetPollTimer: ReturnType<typeof setInterval> | undefined;
308
331
  // Bumped on every overlay mutation / reader reset. An async column build captures it before its awaits
309
332
  // and only caches its result if it hasn't changed since — so a write or reset mid-build (which clears
310
333
  // the cache and may swap the reader) can never leave a stale entry behind.
@@ -468,6 +491,8 @@ export class BulkDatabaseBase<T extends { key: string }> {
468
491
  // Live keys of this reader, so per-column cache invalidation can tell an update of an existing key
469
492
  // (only its set columns change) from a key add/remove (which touches every column).
470
493
  this.readerKeys = new Set(joined.keys);
494
+ // The files this index was built from, so the poll can detect a concurrent merge changing the set.
495
+ this.loadedFileSet = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
471
496
 
472
497
  let time = Date.now() - start;
473
498
  if (time > 50) {
@@ -513,31 +538,85 @@ export class BulkDatabaseBase<T extends { key: string }> {
513
538
  });
514
539
  }
515
540
 
516
- // Reset the loaded reader and all derived in-memory caches/overlay. Used only on structural
517
- // changes (large direct-bulk write, rollover, compact) after the data has been persisted. Writes
518
- // still buffered (not yet on disk) are re-applied so the reset doesn't drop them from reads — they
519
- // aren't in the reloaded reader until their append lands.
541
+ // Drop the loaded index (reader) and everything derived from it, so the next read rebuilds via
542
+ // buildReader. Does NOT touch the overlay callers decide what to do with pending writes.
543
+ private clearReaderState() {
544
+ this.reader.reset();
545
+ this.baseColumns.clear();
546
+ this.baseColumnsLoading.clear();
547
+ this.baseFields.clear();
548
+ this.baseFieldsLoading.clear();
549
+ this.columnCache.clear();
550
+ this.readerKeys = undefined; // the next build repopulates it
551
+ this.loadedFileSet = undefined; // ditto
552
+ // The next build re-measures the stream; clear the estimate so a just-folded stream doesn't keep
553
+ // looking "heavy" and re-trigger a fold before then.
554
+ this.streamRowsOnDisk = 0;
555
+ this.streamBytesOnDisk = 0;
556
+ this.dataGen++;
557
+ this.readerEpoch++; // so an in-flight read knows the index changed under it (see readWithReload)
558
+ }
559
+
560
+ // Reset the loaded reader AND drop the overlay. Used only on structural changes WE made (large
561
+ // direct-bulk write, rollover, compact) after the data has been persisted. Writes still buffered (not
562
+ // yet on disk) are re-applied so the reset doesn't drop them from reads — they aren't in the reloaded
563
+ // reader until their append lands.
520
564
  private resetReader() {
521
565
  this.deps.batch(() => {
522
- this.reader.reset();
523
- this.baseColumns.clear();
524
- this.baseColumnsLoading.clear();
525
- this.baseFields.clear();
526
- this.baseFieldsLoading.clear();
566
+ this.clearReaderState();
527
567
  this.overlay.clear();
528
- this.dataGen++;
529
- this.columnCache.clear();
530
- this.readerKeys = undefined; // the next reader build repopulates it
531
- // The next reader build re-measures the stream; clear the estimate so a just-folded stream
532
- // doesn't keep looking "heavy" and re-trigger a fold before then.
533
- this.streamRowsOnDisk = 0;
534
- this.streamBytesOnDisk = 0;
535
568
  for (const p of this.pendingAppends) p.apply();
536
569
  this.invalidateSignal(LOAD_SIGNAL);
537
570
  this.invalidateSignal(OVERLAY_SIGNAL);
538
571
  });
539
572
  }
540
573
 
574
+ // Reload just the on-disk index after the file set changed UNDER us (a concurrent merge). Keeps the
575
+ // overlay: our pending writes are in-memory and independent of which files exist on disk.
576
+ private reloadReader() {
577
+ this.deps.batch(() => {
578
+ this.clearReaderState();
579
+ this.invalidateSignal(LOAD_SIGNAL);
580
+ this.invalidateSignal(OVERLAY_SIGNAL);
581
+ });
582
+ }
583
+
584
+ // Run a read against the loaded index; if a file vanished mid-read (a concurrent merge deleted it),
585
+ // reload the index and retry — looping until it succeeds or we've tried MAX_INDEX_RELOAD_ATTEMPTS times.
586
+ // Reloads coalesce: a failing read only resets the index if nobody has reset it since the read grabbed
587
+ // its reader (readerEpoch unchanged). So N concurrent failures cause ONE rebuild — the first resets, the
588
+ // rest see the bumped epoch and just await the in-flight rebuild that lazy() shares — and a good rebuild
589
+ // is never thrown away by a straggler.
590
+ private async readWithReload<R>(fn: (reader: ResolvedReader) => Promise<R>): Promise<R> {
591
+ for (let attempt = 0; ; attempt++) {
592
+ const reader = await this.reader();
593
+ const epoch = this.readerEpoch; // epoch of the reader we're about to use
594
+ try {
595
+ return await fn(reader);
596
+ } catch (e) {
597
+ if (!(e instanceof MissingFileError) || attempt >= MAX_INDEX_RELOAD_ATTEMPTS) throw e;
598
+ if (this.readerEpoch === epoch) this.reloadReader();
599
+ // else: another reader already reloaded the index — loop and use its rebuild.
600
+ }
601
+ }
602
+ }
603
+
604
+ // Proactively reload the index if the on-disk file set changed under us (a merge in another tab/
605
+ // process), so reads pick up the new files even without first hitting a read error. Cheap — just lists
606
+ // the directory. Runs on a timer (fileSetPollIntervalMs).
607
+ private async pollFileSet(): Promise<void> {
608
+ if (!this.loadedFileSet) return; // reader not built yet → nothing loaded to compare against
609
+ let current: Set<string>;
610
+ try {
611
+ const { bulkFiles, streamFiles } = await this.listFiles();
612
+ current = new Set([...bulkFiles.map(f => f.fileName), ...streamFiles.map(f => f.fileName)]);
613
+ } catch { return; }
614
+ const prev = this.loadedFileSet;
615
+ if (!prev) return; // reloaded during the await
616
+ const changed = current.size !== prev.size || [...current].some(n => !prev.has(n));
617
+ if (changed) this.reloadReader();
618
+ }
619
+
541
620
  // ---- writes ----
542
621
 
543
622
  public async write(entry: T): Promise<void> {
@@ -1210,11 +1289,10 @@ export class BulkDatabaseBase<T extends { key: string }> {
1210
1289
  // column not set in the overlay entry — fall through to disk for this column
1211
1290
  }
1212
1291
  let time = Date.now();
1213
- let reader = await this.reader();
1214
- const r = await reader.getSingleField(key, col);
1292
+ const r = await this.readWithReload(reader => reader.getSingleField(key, col));
1215
1293
  time = Date.now() - time;
1216
1294
  if (time > 50) {
1217
- console.log(`${blue(`${this.name}.getSingleFieldObj(${JSON.stringify(key)}, ${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
1295
+ console.log(`${blue(`${this.name}.getSingleFieldObj(${JSON.stringify(key)}, ${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(await this.reader())}`);
1218
1296
  }
1219
1297
  if (r === undefined) {
1220
1298
  // Not live on disk; but if the overlay holds the key (a partial write of a not-yet-on-disk
@@ -1232,12 +1310,11 @@ export class BulkDatabaseBase<T extends { key: string }> {
1232
1310
  if (cached) return cached as { key: string; value: T[Column]; time: number }[];
1233
1311
  const gen = this.dataGen;
1234
1312
  let time = Date.now();
1235
- let reader = await this.reader();
1236
- let base = await reader.getColumn(col);
1313
+ let base = await this.readWithReload(reader => reader.getColumn(col));
1237
1314
  let result = this.patchColumn(base, col) as { key: string; value: T[Column]; time: number }[];
1238
1315
  time = Date.now() - time;
1239
1316
  if (time > 50) {
1240
- console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(reader)}`);
1317
+ console.log(`${blue(`${this.name}.getColumn(${JSON.stringify(column)})`)} took ${red(formatTime(time))} ${this.formatInfo(await this.reader())}`);
1241
1318
  }
1242
1319
  Object.freeze(result);
1243
1320
  // Only cache if no write/reset happened during the awaits above (else this result may be stale).
@@ -1274,13 +1351,19 @@ export class BulkDatabaseBase<T extends { key: string }> {
1274
1351
  if (this.baseColumns.has(column) || this.baseColumnsLoading.has(column)) return;
1275
1352
  this.baseColumnsLoading.add(column);
1276
1353
  void (async () => {
1277
- let reader = await this.reader();
1278
- let base = await reader.getColumn(column);
1279
- this.deps.batch(() => {
1280
- this.baseColumns.set(column, base);
1354
+ try {
1355
+ const base = await this.readWithReload(reader => reader.getColumn(column));
1356
+ this.deps.batch(() => {
1357
+ this.baseColumns.set(column, base);
1358
+ this.baseColumnsLoading.delete(column);
1359
+ this.invalidateSignal(LOAD_SIGNAL);
1360
+ });
1361
+ } catch (e) {
1362
+ // The load failed (e.g. a file vanished and the reload retry also failed). Clear the loading
1363
+ // flag so a later read retries, rather than leaving the column wedged as "loading" forever.
1281
1364
  this.baseColumnsLoading.delete(column);
1282
- this.invalidateSignal(LOAD_SIGNAL);
1283
- });
1365
+ console.warn(`${this.name}.getColumnSync(${JSON.stringify(column)}) load failed, will retry: ${(e as Error).message}`);
1366
+ }
1284
1367
  })();
1285
1368
  }
1286
1369
 
@@ -1289,13 +1372,17 @@ export class BulkDatabaseBase<T extends { key: string }> {
1289
1372
  if (this.baseFields.has(cacheKey) || this.baseFieldsLoading.has(cacheKey)) return;
1290
1373
  this.baseFieldsLoading.add(cacheKey);
1291
1374
  void (async () => {
1292
- let reader = await this.reader();
1293
- let resolved = await reader.getSingleField(key, column);
1294
- this.deps.batch(() => {
1295
- this.baseFields.set(cacheKey, resolved);
1375
+ try {
1376
+ const resolved = await this.readWithReload(reader => reader.getSingleField(key, column));
1377
+ this.deps.batch(() => {
1378
+ this.baseFields.set(cacheKey, resolved);
1379
+ this.baseFieldsLoading.delete(cacheKey);
1380
+ this.invalidateSignal(LOAD_SIGNAL);
1381
+ });
1382
+ } catch (e) {
1296
1383
  this.baseFieldsLoading.delete(cacheKey);
1297
- this.invalidateSignal(LOAD_SIGNAL);
1298
- });
1384
+ console.warn(`${this.name}.getSingleFieldSync(${JSON.stringify(key)}, ${JSON.stringify(column)}) load failed, will retry: ${(e as Error).message}`);
1385
+ }
1299
1386
  })();
1300
1387
  }
1301
1388
 
@@ -46,6 +46,7 @@ export type FileWrapper = {
46
46
  write(value: Buffer): Promise<void>;
47
47
  close(): Promise<void>;
48
48
  }>;
49
+ getURL?(): Promise<string>;
49
50
  };
50
51
  export type DirectoryWrapper = {
51
52
  readonly kind: "directory";
@@ -78,6 +79,7 @@ export declare class NodeJSFileHandleWrapper implements FileWrapper {
78
79
  arrayBuffer: () => Promise<ArrayBuffer>;
79
80
  };
80
81
  }>;
82
+ getURL(): Promise<string>;
81
83
  createWritable(config?: {
82
84
  keepExistingData?: boolean;
83
85
  }): Promise<{
@@ -142,5 +144,7 @@ export type FileStorage = IStorageRaw & {
142
144
  isRemote?: boolean;
143
145
  };
144
146
  export declare function wrapHandle(handle: DirectoryWrapper): FileStorage;
147
+ export declare function getFileURL(file: FileWrapper): Promise<string>;
148
+ export declare function disposeFileURL(url: string): void;
145
149
  export declare function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage>;
146
150
  export declare function tryToLoadPointer(pointer: string): Promise<DirectoryWrapper | undefined>;
@@ -56,6 +56,10 @@ export type FileWrapper = {
56
56
  write(value: Buffer): Promise<void>;
57
57
  close(): Promise<void>;
58
58
  }>;
59
+ // Returns a URL for the file's bytes, usable in <video>/<img>/fetch. Optional — the native browser
60
+ // FileSystemFileHandle has no such method, so prefer the getFileURL() helper, which falls back to a
61
+ // blob: URL via createObjectURL for native handles. Always release it with disposeFileURL when done.
62
+ getURL?(): Promise<string>;
59
63
  };
60
64
  export type DirectoryWrapper = {
61
65
  readonly kind: "directory";
@@ -261,6 +265,10 @@ export class NodeJSFileHandleWrapper implements FileWrapper {
261
265
  };
262
266
  }
263
267
 
268
+ async getURL() {
269
+ return "file://" + path.resolve(this.filePath);
270
+ }
271
+
264
272
  async createWritable(config?: { keepExistingData?: boolean }) {
265
273
  let fileHandle: fs.promises.FileHandle;
266
274
  const flags = config?.keepExistingData ? "r+" : "w";
@@ -715,6 +723,25 @@ export function wrapHandle(handle: DirectoryWrapper): FileStorage {
715
723
  };
716
724
  }
717
725
 
726
+ // Returns a URL for a file's bytes, ready to drop into <video>/<img>/fetch. A native (local) file becomes
727
+ // an in-memory blob: URL; a remote file becomes an https URL into the server's range-capable /media
728
+ // endpoint (auth token in the query, since a media element can't send headers). Both support HTTP range /
729
+ // seeking. ALWAYS hand the result to disposeFileURL when finished — for blob: URLs that frees memory.
730
+ export async function getFileURL(file: FileWrapper): Promise<string> {
731
+ if (file.getURL) return file.getURL();
732
+ // Native FileSystemFileHandle (or any Blob-backed file): a blob: URL over the File itself.
733
+ const f = await file.getFile();
734
+ return URL.createObjectURL(f as unknown as Blob);
735
+ }
736
+
737
+ // Releases a URL from getFileURL. blob: URLs leak until the document is gone unless revoked; https/file:
738
+ // URLs need no cleanup, so this is a no-op for them.
739
+ export function disposeFileURL(url: string): void {
740
+ if (url.startsWith("blob:")) {
741
+ try { URL.revokeObjectURL(url); } catch { /* not in a browser, or already revoked */ }
742
+ }
743
+ }
744
+
718
745
  // A StorageFactory backed by a remote server (path -> FileStorage), for code that injects its own
719
746
  // storage into BulkDatabase2 rather than going through the directory prompt.
720
747
  export function getRemoteFileStorageFactory(url: string, password: string, options?: RemoteOptions): (pathStr: string) => Promise<FileStorage> {
@@ -274,6 +274,40 @@ function formatBytes(n: number): string {
274
274
  return (n / 1024 / 1024 / 1024).toFixed(2) + "GB";
275
275
  }
276
276
 
277
+ // Parse a single-range HTTP Range header ("bytes=start-end", "bytes=start-", "bytes=-suffix") into an
278
+ // INCLUSIVE [start, end]. Returns "unsatisfiable" for a range past the file, or undefined for no/garbled
279
+ // range (caller then serves the whole file). Only single ranges are supported (what media players use).
280
+ function parseRange(header: string, size: number): { start: number; end: number } | "unsatisfiable" | undefined {
281
+ const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
282
+ if (!m) return undefined;
283
+ const sRaw = m[1], eRaw = m[2];
284
+ if (sRaw === "" && eRaw === "") return undefined;
285
+ let start: number, end: number;
286
+ if (sRaw === "") {
287
+ const suffix = parseInt(eRaw, 10);
288
+ if (!suffix) return "unsatisfiable";
289
+ start = Math.max(0, size - suffix);
290
+ end = size - 1;
291
+ } else {
292
+ start = parseInt(sRaw, 10);
293
+ end = eRaw === "" ? size - 1 : parseInt(eRaw, 10);
294
+ }
295
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) return "unsatisfiable";
296
+ return { start, end: Math.min(end, size - 1) };
297
+ }
298
+
299
+ const MEDIA_TYPES: Record<string, string> = {
300
+ mp4: "video/mp4", m4v: "video/mp4", webm: "video/webm", mkv: "video/x-matroska", mov: "video/quicktime",
301
+ avi: "video/x-msvideo", ogv: "video/ogg", ts: "video/mp2t",
302
+ mp3: "audio/mpeg", m4a: "audio/mp4", aac: "audio/aac", flac: "audio/flac", wav: "audio/wav", ogg: "audio/ogg", opus: "audio/opus",
303
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", avif: "image/avif", svg: "image/svg+xml",
304
+ pdf: "application/pdf", txt: "text/plain; charset=utf-8", json: "application/json",
305
+ };
306
+ function mediaContentType(filePath: string): string {
307
+ const ext = filePath.slice(filePath.lastIndexOf(".") + 1).toLowerCase();
308
+ return MEDIA_TYPES[ext] || "application/octet-stream";
309
+ }
310
+
277
311
  // Public (no-auth) landing page. The browser can't trust a self-signed cert from a background fetch, so
278
312
  // the user opens this URL once, accepts the browser's security warning, and the cert becomes trusted for
279
313
  // the origin — then the app's fetches work. This page is what they see after accepting.
@@ -336,8 +370,9 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
336
370
  const origin = (req.headers["origin"] as string) || "*";
337
371
  const cors: Record<string, string> = {
338
372
  "Access-Control-Allow-Origin": origin,
339
- "Access-Control-Allow-Methods": "GET, PUT, POST, DELETE, OPTIONS",
340
- "Access-Control-Allow-Headers": "authorization, content-type",
373
+ "Access-Control-Allow-Methods": "GET, HEAD, PUT, POST, DELETE, OPTIONS",
374
+ "Access-Control-Allow-Headers": "authorization, content-type, range",
375
+ "Access-Control-Expose-Headers": "content-range, accept-ranges, content-length",
341
376
  "Access-Control-Max-Age": "86400",
342
377
  "Vary": "Origin",
343
378
  };
@@ -359,7 +394,8 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
359
394
  }
360
395
 
361
396
  const auth = (req.headers["authorization"] as string) || "";
362
- const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
397
+ // Bearer header for API clients; ?token= query for media URLs (a <video> element can't set headers).
398
+ const token = (auth.startsWith("Bearer ") ? auth.slice(7) : "") || url.searchParams.get("token") || "";
363
399
  if (!timingSafeEqualStr(normalizePassword(token), normPassword)) return sendJson(401, { error: "unauthorized" });
364
400
 
365
401
  const relPath = url.searchParams.get("path") || "";
@@ -391,6 +427,33 @@ export function startRemoteFileServer(options: RemoteFileServerOptions): Promise
391
427
  fs.createReadStream(full, { start, end: end - 1 }).on("error", () => res.end()).pipe(res);
392
428
  return;
393
429
  }
430
+ // Range-capable media endpoint for <video>/<img>/fetch: honors the Range header (206 +
431
+ // Content-Range + Accept-Ranges) so seeking works. Auth came from the ?token= query above.
432
+ if ((req.method === "GET" || req.method === "HEAD") && op === "/media") {
433
+ let st: fs.Stats;
434
+ try { st = fs.statSync(full); } catch { return sendJson(404, { error: "not found" }); }
435
+ if (st.isDirectory()) return sendJson(400, { error: "is a directory" });
436
+ const rangeHeader = req.headers["range"] as string | undefined;
437
+ const range = rangeHeader ? parseRange(rangeHeader, st.size) : undefined;
438
+ if (range === "unsatisfiable") {
439
+ res.writeHead(416, Object.assign({}, cors, { "Content-Range": `bytes */${st.size}`, "Accept-Ranges": "bytes" }));
440
+ return res.end();
441
+ }
442
+ const start = range ? range.start : 0;
443
+ const end = range ? range.end : st.size - 1; // inclusive
444
+ const length = st.size === 0 ? 0 : end - start + 1;
445
+ const headers: Record<string, string> = Object.assign({}, cors, {
446
+ "Content-Type": mediaContentType(full),
447
+ "Accept-Ranges": "bytes",
448
+ "Content-Length": String(length),
449
+ });
450
+ if (range) headers["Content-Range"] = `bytes ${start}-${end}/${st.size}`;
451
+ res.writeHead(range ? 206 : 200, headers);
452
+ if (req.method === "HEAD" || length === 0) return res.end();
453
+ recordAccess(clientIp(req), "read", relPath, length);
454
+ fs.createReadStream(full, { start, end }).on("error", () => res.end()).pipe(res);
455
+ return;
456
+ }
394
457
  if ((req.method === "PUT" || req.method === "POST") && (op === "/append" || op === "/set")) {
395
458
  const body = await readBody(req);
396
459
  fs.mkdirSync(path.dirname(full), { recursive: true });
@@ -358,6 +358,11 @@ class RemoteFileWrapper implements FileWrapper {
358
358
  },
359
359
  };
360
360
  }
361
+ async getURL() {
362
+ // HTTPS URL into the server's range-capable /media endpoint. The token rides in the query because a
363
+ // <video>/<img> element can't send an Authorization header.
364
+ return `${this.conn.url}/media?path=${encodeURIComponent(this.filePath)}&token=${encodeURIComponent(this.conn.password)}`;
365
+ }
361
366
  async createWritable(config?: { keepExistingData?: boolean }) {
362
367
  const conn = this.conn, filePath = this.filePath, append = !!config?.keepExistingData;
363
368
  const chunks: Buffer[] = [];