Server
- /api/download/:id answers Range with a strong ETag ("<id>.<gen>") and honours
If-Range; a stale partial gets the whole current file (streamed, so Bun does
not re-apply the Range itself). Uploads get the same treatment.
- GET /api/download/:id/prepare never blocks: ready {gen,size,sha256,etag,ext},
working (server still fetching), legacy (HEVC / too long / cache offline),
failed. Recently refused prepares are remembered for 10 minutes.
Browser
- The OPFS worker saves in 8 MiB ranges, writes at the byte offset, flushes
each chunk, retries each chunk 6 times with backoff (30 s idle timeout) and
keeps the .part plus a .part.json sidecar naming the server copy it belongs
to. A changed copy restarts cleanly; the finished file is hashed once and
checked against the server's SHA-256.
- SaveQueue remembers unfinished saves and resumes them on start, online,
return to the foreground and every 2 minutes while visible; one at a time.
- Downloads shows live MB progress, 'Preparing on server', 'Verifying', and
paused saves with Resume and Cancel (confirmed).
- listVideos ignores the sidecars; new listPartials/discardPartial helpers.
Verified in Chromium through a connection-dropping proxy: paused at 8 MiB,
auto-resumed after a reload from byte 8388608, final SHA-256 matched.
338 lines
13 KiB
JavaScript
338 lines
13 KiB
JavaScript
/* ============================================================================
|
|
* opfs.js — Origin Private File System video storage for PWA mode
|
|
*
|
|
* Exposes window.OPFS with methods that mirror the Tauri cache_* commands
|
|
* so app.js can call them transparently in WEB mode.
|
|
*
|
|
* Videos are stored under the OPFS root at:
|
|
* videos/<videoId>.<ext>
|
|
*
|
|
* Object URLs created by getFileUrl() are tracked so they can be revoked
|
|
* when no longer needed — call OPFS.revokeUrl(url) after the <video> unloads.
|
|
*
|
|
* OPFS is available in all modern browsers (Chrome 86+, Firefox 111+,
|
|
* Safari 15.2+). Calls degrade gracefully if the API is absent.
|
|
* ========================================================================== */
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// Root directory handle, lazily initialised
|
|
let _rootPromise = null;
|
|
|
|
async function getRoot() {
|
|
if (!_rootPromise) {
|
|
_rootPromise = (async () => {
|
|
const root = await navigator.storage.getDirectory();
|
|
return root.getDirectoryHandle('videos', { create: true });
|
|
})();
|
|
}
|
|
return _rootPromise;
|
|
}
|
|
|
|
// Iterate the videos/ directory and find a file whose stem matches videoId
|
|
// (ignoring the extension). Returns [FileSystemFileHandle, filename] or null.
|
|
async function findHandle(videoId) {
|
|
const dir = await getRoot();
|
|
for await (const [name, handle] of dir.entries()) {
|
|
if (handle.kind !== 'file') continue;
|
|
const dot = name.lastIndexOf('.');
|
|
const stem = dot > -1 ? name.slice(0, dot) : name;
|
|
if (stem === videoId) return [handle, name];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Extension → MIME type for Content-Type headers on playback
|
|
function extToMime(ext) {
|
|
const map = { mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
|
|
m4a: 'audio/mp4', ogg: 'audio/ogg', opus: 'audio/ogg' };
|
|
return map[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
const _blobUrls = new Set();
|
|
|
|
window.OPFS = {
|
|
// Does a file exist for this videoId?
|
|
async hasVideo(videoId) {
|
|
try {
|
|
return (await findHandle(videoId)) !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
// Return a blob: URL usable as <video src>. Returns null if not cached.
|
|
// The caller is responsible for calling OPFS.revokeUrl(url) when done.
|
|
async getFileUrl(videoId) {
|
|
try {
|
|
const found = await findHandle(videoId);
|
|
if (!found) return null;
|
|
const [handle, name] = found;
|
|
const file = await handle.getFile();
|
|
const ext = name.slice(name.lastIndexOf('.') + 1);
|
|
// Wrap the File (a lazy disk-backed Blob) instead of buffering it:
|
|
// `await file.arrayBuffer()` here pulled the ENTIRE video into main-
|
|
// thread memory just to retype it, which froze the UI and stuttered
|
|
// audio on phones. Blob parts reference the File without reading it.
|
|
const blob = new Blob([file], { type: extToMime(ext) });
|
|
const url = URL.createObjectURL(blob);
|
|
_blobUrls.add(url);
|
|
return url;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
// Revoke an object URL previously returned by getFileUrl().
|
|
// 'hevc' | 'avc' | null for a stored mp4. Server copies are faststart, so
|
|
// the sample description (hvc1/hev1 vs avc1) sits in the first megabyte.
|
|
async sniffCodec(videoId) {
|
|
try {
|
|
const found = await findHandle(videoId);
|
|
if (!found) return null;
|
|
const file = await found[0].getFile();
|
|
const head = new Uint8Array(await file.slice(0, 1024 * 1024).arrayBuffer());
|
|
const has = (tag) => {
|
|
const [a, b, c, d] = [...tag].map((ch) => ch.charCodeAt(0));
|
|
for (let i = 0; i + 3 < head.length; i++) {
|
|
if (head[i] === a && head[i + 1] === b && head[i + 2] === c && head[i + 3] === d) return true;
|
|
}
|
|
return false;
|
|
};
|
|
if (has('hvc1') || has('hev1')) return 'hevc';
|
|
if (has('avc1')) return 'avc';
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
// The saved File itself (disk-backed, not read into memory) — used as an
|
|
// upload body by P2P intake. null when not saved.
|
|
async getFileObject(videoId) {
|
|
try {
|
|
const found = await findHandle(videoId);
|
|
return found ? await found[0].getFile() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
// Bytes [offset, offset+length) of a saved video — answers the server's
|
|
// P2P range challenges (p2p-client.js). null when the file is missing.
|
|
async readRange(videoId, offset, length) {
|
|
try {
|
|
const found = await findHandle(videoId);
|
|
if (!found) return null;
|
|
const file = await found[0].getFile();
|
|
return new Uint8Array(await file.slice(offset, offset + length).arrayBuffer());
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
revokeUrl(url) {
|
|
if (url && _blobUrls.has(url)) {
|
|
URL.revokeObjectURL(url);
|
|
_blobUrls.delete(url);
|
|
}
|
|
},
|
|
|
|
// Download a video entirely inside a dedicated Web Worker — the fetch and
|
|
// the OPFS writes both happen off the main thread, so saves can never
|
|
// jank the UI. One worker per download; concurrent saves get concurrent
|
|
// workers. Resolves { ok:true } on success, { ok:false, error } on a real
|
|
// failure, or { ok:false, fallback:true } when the worker path is
|
|
// unavailable and the caller should use writeFromResponse instead.
|
|
// opts.resumable = { etag, size, sha256, ext } switches the worker to the
|
|
// ranged, resumable save; opts.onProgress(received, total, info) reports.
|
|
downloadVideo(videoId, url, opts = {}) {
|
|
return new Promise((resolve) => {
|
|
let worker;
|
|
try {
|
|
worker = new Worker('/opfs-worker.js');
|
|
} catch {
|
|
resolve({ ok: false, fallback: true });
|
|
return;
|
|
}
|
|
const finish = (result) => {
|
|
worker.terminate();
|
|
resolve(result);
|
|
};
|
|
worker.onmessage = (e) => {
|
|
const m = e.data || {};
|
|
if (m.type === 'done') finish({ ok: true, sha256: m.sha256 || null, expectedSha: m.expectedSha || null, size: m.size || 0 });
|
|
else if (m.type === 'unsupported') finish({ ok: false, fallback: true });
|
|
else if (m.type === 'error') finish({ ok: false, error: m.error });
|
|
else if (m.type === 'paused') finish({ ok: false, paused: true, received: m.received || 0, error: m.error });
|
|
else if (m.type === 'changed') finish({ ok: false, changed: true });
|
|
else if (m.type === 'progress' && opts.onProgress) {
|
|
try { opts.onProgress(m.received || 0, m.total || 0, m); } catch { /* UI only */ }
|
|
}
|
|
};
|
|
worker.onerror = () => finish({ ok: false, fallback: true });
|
|
worker.postMessage({ videoId, url, resumable: opts.resumable || null });
|
|
});
|
|
},
|
|
|
|
// Stream a fetch Response body into OPFS. Uses a writable stream so only
|
|
// a small chunk lives in memory at a time (no full-file buffering).
|
|
// Falls back to ArrayBuffer if WritableStream is unavailable.
|
|
// Main-thread fallback for downloadVideo — used when Workers or
|
|
// createSyncAccessHandle are unavailable.
|
|
async writeFromResponse(videoId, ext, response) {
|
|
const dir = await getRoot();
|
|
const filename = videoId + '.' + (ext || 'mp4');
|
|
|
|
// Write to a temporary file first so a partial download doesn't leave
|
|
// a corrupt permanent entry.
|
|
const tmpName = filename + '.part';
|
|
const tmpHandle = await dir.getFileHandle(tmpName, { create: true });
|
|
|
|
try {
|
|
if ('createWritable' in tmpHandle) {
|
|
const writable = await tmpHandle.createWritable();
|
|
try {
|
|
if (response.body && typeof response.body.pipeTo === 'function') {
|
|
await response.body.pipeTo(writable);
|
|
} else {
|
|
// Safari < 16.4 doesn't support pipeTo — buffer the whole response
|
|
const buf = await response.arrayBuffer();
|
|
await writable.write(buf);
|
|
await writable.close();
|
|
}
|
|
} catch (err) {
|
|
await writable.abort();
|
|
throw err;
|
|
}
|
|
// A connection that ends early can close the body cleanly, leaving
|
|
// a truncated file that would be badged "cached" yet never play.
|
|
const expected = Number(response.headers.get('content-length'));
|
|
if (expected > 0) {
|
|
const got = (await tmpHandle.getFile()).size;
|
|
if (got !== expected) throw new Error(`download cut short (${got} of ${expected} bytes)`);
|
|
}
|
|
} else {
|
|
// No createWritable on this browser — main-thread OPFS writes are
|
|
// impossible (sync access handles are worker-only). Surface a real
|
|
// error instead of the old branch that called the missing API.
|
|
throw new Error('Offline saving is not supported in this browser');
|
|
}
|
|
|
|
// Rename tmp → final. Prefer the native rename; else stream-copy so
|
|
// the whole file is never buffered in main-thread memory at once.
|
|
// move() failures (WebKit's signature differs from Chrome's and
|
|
// throws TypeError) fall back to the copy path too.
|
|
let renamed = false;
|
|
if (typeof tmpHandle.move === 'function') {
|
|
try { await tmpHandle.move(filename); renamed = true; } catch { /* copy below */ }
|
|
}
|
|
if (!renamed) {
|
|
const finalHandle = await dir.getFileHandle(filename, { create: true });
|
|
const finalWritable = await finalHandle.createWritable();
|
|
const tmpFile = await tmpHandle.getFile();
|
|
const tmpStream = typeof tmpFile.stream === 'function' ? tmpFile.stream() : null;
|
|
if (tmpStream && typeof tmpStream.pipeTo === 'function') {
|
|
await tmpStream.pipeTo(finalWritable); // pipeTo closes the writable
|
|
} else {
|
|
await finalWritable.write(await tmpFile.arrayBuffer());
|
|
await finalWritable.close();
|
|
}
|
|
}
|
|
} finally {
|
|
// Remove .part file regardless
|
|
try { await dir.removeEntry(tmpName); } catch { /* already gone */ }
|
|
}
|
|
},
|
|
|
|
// List all cached videos — returns [{ id, size, name }]
|
|
// strict: rethrow instead of returning a partial list, for callers that
|
|
// treat a missing entry as "the file is gone".
|
|
async listVideos({ strict = false } = {}) {
|
|
const dir = await getRoot();
|
|
const items = [];
|
|
try {
|
|
for await (const [name, handle] of dir.entries()) {
|
|
if (handle.kind !== 'file') continue;
|
|
// Skip .part temporary files and resumable-save sidecars
|
|
if (name.endsWith('.part') || name.endsWith('.part.json')) continue;
|
|
const dot = name.lastIndexOf('.');
|
|
const id = dot > -1 ? name.slice(0, dot) : name;
|
|
const file = await handle.getFile();
|
|
items.push({ id, name, size: file.size });
|
|
}
|
|
} catch (err) { if (strict) throw err; /* OPFS not available */ }
|
|
return items;
|
|
},
|
|
|
|
// Paused resumable saves: [{ id, received, size }] from <id>.<ext>.part
|
|
// and its .part.json sidecar (written by opfs-worker.js).
|
|
async listPartials() {
|
|
const out = [];
|
|
try {
|
|
const dir = await getRoot();
|
|
for await (const [name, handle] of dir.entries()) {
|
|
if (handle.kind !== 'file' || !name.endsWith('.part.json')) continue;
|
|
const partName = name.slice(0, -5);
|
|
let meta = null, received = 0;
|
|
try { meta = JSON.parse(await (await handle.getFile()).text()); } catch { /* unreadable */ }
|
|
try { received = (await (await dir.getFileHandle(partName)).getFile()).size; } catch { /* no data yet */ }
|
|
const id = partName.replace(/\.[^.]+\.part$/, '');
|
|
out.push({ id, received, size: (meta && meta.size) || 0 });
|
|
}
|
|
} catch { /* OPFS not available */ }
|
|
return out;
|
|
},
|
|
|
|
// Drop a paused save's partial data (Cancel in Downloads).
|
|
async discardPartial(videoId) {
|
|
try {
|
|
const dir = await getRoot();
|
|
const names = [];
|
|
for await (const [name] of dir.entries()) {
|
|
if ((name.endsWith('.part') || name.endsWith('.part.json')) && name.startsWith(videoId + '.')) names.push(name);
|
|
}
|
|
for (const name of names) await dir.removeEntry(name).catch(() => {});
|
|
} catch { /* OPFS not available */ }
|
|
},
|
|
|
|
// Total bytes stored
|
|
async totalSize() {
|
|
const items = await this.listVideos();
|
|
return items.reduce((s, i) => s + i.size, 0);
|
|
},
|
|
|
|
// Delete one video
|
|
async deleteVideo(videoId) {
|
|
try {
|
|
const found = await findHandle(videoId);
|
|
if (!found) return;
|
|
const [, name] = found;
|
|
const dir = await getRoot();
|
|
await dir.removeEntry(name);
|
|
} catch { /* already gone */ }
|
|
},
|
|
|
|
// Delete all cached videos
|
|
async clearAll() {
|
|
try {
|
|
const dir = await getRoot();
|
|
const names = [];
|
|
for await (const [name] of dir.entries()) names.push(name);
|
|
await Promise.all(names.map((n) => dir.removeEntry(n).catch(() => {})));
|
|
// Revoke any outstanding blob URLs
|
|
for (const url of _blobUrls) URL.revokeObjectURL(url);
|
|
_blobUrls.clear();
|
|
} catch { /* OPFS not available */ }
|
|
},
|
|
|
|
// Is the OPFS API supported in this browser?
|
|
isSupported() {
|
|
return typeof navigator !== 'undefined' &&
|
|
typeof navigator.storage !== 'undefined' &&
|
|
typeof navigator.storage.getDirectory === 'function';
|
|
},
|
|
};
|
|
}());
|