Resumable saves: ranged downloads that survive dropped connections, reloads and app kills

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.
This commit is contained in:
Jonathan Sykes
2026-10-03 00:46:52 +08:00
parent c8601c5953
commit 56b3f4e449
10 changed files with 698 additions and 63 deletions

View File

@@ -145,7 +145,9 @@
// 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.
downloadVideo(videoId, url) {
// 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 {
@@ -163,10 +165,14 @@
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 });
// 'progress' messages are informational; ignored here
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 });
worker.postMessage({ videoId, url, resumable: opts.resumable || null });
});
},
@@ -249,8 +255,8 @@
try {
for await (const [name, handle] of dir.entries()) {
if (handle.kind !== 'file') continue;
// Skip .part temporary files
if (name.endsWith('.part')) 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();
@@ -260,6 +266,37 @@
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();