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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user