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.
45 lines
1.8 KiB
JavaScript
45 lines
1.8 KiB
JavaScript
/* ============================================================================
|
|
* resume-core — pure helpers for resumable saves (docs/resumable-downloads-plan.md)
|
|
*
|
|
* Framework-free: a plain <script>/importScripts global (`ResumeCore`) in the
|
|
* browser and worker, `require`-able by `node --test`.
|
|
* ========================================================================== */
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
const CHUNK = 8 * 1024 * 1024;
|
|
|
|
/** Next byte range to fetch, or null when the file is complete. */
|
|
function nextRange(pos, size, chunk = CHUNK) {
|
|
if (!(size > 0) || pos >= size) return null;
|
|
return { start: pos, end: Math.min(pos + chunk, size) - 1 };
|
|
}
|
|
|
|
/** "bytes 0-99/1234" → { start, end, total }; null when malformed. */
|
|
function parseContentRange(h) {
|
|
const m = /^bytes (\d+)-(\d+)\/(\d+|\*)$/.exec(String(h || '').trim());
|
|
if (!m) return null;
|
|
return { start: Number(m[1]), end: Number(m[2]), total: m[3] === '*' ? null : Number(m[3]) };
|
|
}
|
|
|
|
/** Wait before retry `attempt` (0-based): 2 s, 4 s, 8 s, 16 s, 30 s cap. */
|
|
function backoffMs(attempt) {
|
|
return Math.min(30000, 2000 * Math.pow(2, Math.max(0, attempt)));
|
|
}
|
|
|
|
/**
|
|
* Where a stored partial may resume from. The partial is only reused when
|
|
* it was written for the very same server copy (ETag) and is not longer
|
|
* than that copy; otherwise start over.
|
|
*/
|
|
function resumeFrom(sidecar, partSize, target) {
|
|
if (!sidecar || !target || sidecar.etag !== target.etag || sidecar.size !== target.size) return 0;
|
|
if (!(partSize > 0)) return 0;
|
|
return Math.min(partSize, target.size);
|
|
}
|
|
|
|
const ResumeCore = { CHUNK, nextRange, parseContentRange, backoffMs, resumeFrom };
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = ResumeCore;
|
|
else root.ResumeCore = ResumeCore;
|
|
})(typeof self !== 'undefined' ? self : this);
|