Files
ytplayer/frontend/resume-core.js
Jonathan Sykes 56b3f4e449 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.
2026-10-03 00:46:52 +08:00

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);