1923ff2a6f
The browser upload flow was built around one selected file and one global upload state. That made the existing chunk pool useful for a single file, but users could not start several selected files at the same time. Refactor the browser state into per-file upload items. Each selected file now has its own upload record, completed-chunk set, abort controller, retry state, progress row, and saved IndexedDB resume record. The picker accepts multiple files, `Start all` and `Resume all` use a bounded file-level pool, and each file keeps the existing bounded chunk pool. This keeps parallel uploads useful without letting one large selection create unbounded request fan-out. Keep the server API unchanged. Each file still receives a separate server upload id, and server-side progress remains authoritative before any missing chunks are scheduled. Terminal conflicts still stop the affected file without overwriting completed data. Update the user-facing markup, styles, project docs, and test checklist for the multi-file scheduler. Add a server regression test that interleaves two uploads and verifies the completed files contain exactly their own bytes. Test Plan: - just check - git diff --check
933 lines
24 KiB
JavaScript
933 lines
24 KiB
JavaScript
const DB_NAME = "upl";
|
|
const DB_VERSION = 1;
|
|
const STORE_NAME = "uploads";
|
|
const CHUNK_CONCURRENCY = 3;
|
|
const FILE_CONCURRENCY = 3;
|
|
const MAX_RETRIES = 5;
|
|
const BASE_RETRY_DELAY_MS = 500;
|
|
const COMPLETE_EXISTS_MESSAGE = "complete file already exists";
|
|
|
|
const fileInput = document.querySelector("#file-input");
|
|
const pickButton = document.querySelector("#pick-button");
|
|
const uploadSection = document.querySelector("#upload-section");
|
|
const uploadList = document.querySelector("#upload-list");
|
|
const startButton = document.querySelector("#start-button");
|
|
const pauseButton = document.querySelector("#pause-button");
|
|
const resumeButton = document.querySelector("#resume-button");
|
|
const eventLog = document.querySelector("#event-log");
|
|
const pendingSection = document.querySelector("#pending-section");
|
|
const pendingList = document.querySelector("#pending-list");
|
|
|
|
const state = {
|
|
pendingRecords: [],
|
|
resumeAfterReselect: null,
|
|
schedulerAbortController: null,
|
|
schedulerRunning: false,
|
|
uploadItems: [],
|
|
};
|
|
|
|
const dbReady = "indexedDB" in window ? openDatabase() : Promise.resolve(null);
|
|
let nextUploadItemId = 1;
|
|
let saveChain = Promise.resolve();
|
|
|
|
function formatBytes(bytes) {
|
|
const formatter = new Intl.NumberFormat(undefined, {
|
|
maximumFractionDigits: 1,
|
|
});
|
|
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
let value = bytes;
|
|
let unit = units[0];
|
|
|
|
for (const candidate of units) {
|
|
unit = candidate;
|
|
if (value < 1024 || candidate === units[units.length - 1]) {
|
|
break;
|
|
}
|
|
value /= 1024;
|
|
}
|
|
|
|
return `${formatter.format(value)} ${unit}`;
|
|
}
|
|
|
|
function log(message) {
|
|
const item = document.createElement("li");
|
|
item.textContent = message;
|
|
eventLog.prepend(item);
|
|
|
|
while (eventLog.children.length > 8) {
|
|
eventLog.lastElementChild.remove();
|
|
}
|
|
}
|
|
|
|
function openDatabase() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
request.addEventListener("upgradeneeded", () => {
|
|
const db = request.result;
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
db.createObjectStore(STORE_NAME, { keyPath: "upload_id" });
|
|
}
|
|
});
|
|
|
|
request.addEventListener("success", () => resolve(request.result));
|
|
request.addEventListener("error", () => reject(request.error));
|
|
}).catch((error) => {
|
|
log(`IndexedDB unavailable: ${error.message}`);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function requestToPromise(request) {
|
|
return new Promise((resolve, reject) => {
|
|
request.addEventListener("success", () => resolve(request.result));
|
|
request.addEventListener("error", () => reject(request.error));
|
|
});
|
|
}
|
|
|
|
async function withStore(mode, callback) {
|
|
const db = await dbReady;
|
|
if (!db) {
|
|
return null;
|
|
}
|
|
|
|
const transaction = db.transaction(STORE_NAME, mode);
|
|
return requestToPromise(callback(transaction.objectStore(STORE_NAME)));
|
|
}
|
|
|
|
async function loadRecords() {
|
|
const records = (await withStore("readonly", (store) => store.getAll())) ?? [];
|
|
records.sort((left, right) =>
|
|
(right.updated_at ?? "").localeCompare(left.updated_at ?? ""),
|
|
);
|
|
state.pendingRecords = records;
|
|
renderPendingRecords();
|
|
}
|
|
|
|
async function saveRecord(record) {
|
|
const nextSave = saveChain.catch(() => null).then(() => writeRecord(record));
|
|
saveChain = nextSave;
|
|
return nextSave;
|
|
}
|
|
|
|
async function writeRecord(record) {
|
|
const storedRecord = { ...record, updated_at: new Date().toISOString() };
|
|
try {
|
|
await withStore("readwrite", (store) => store.put(storedRecord));
|
|
} catch (error) {
|
|
if (!storedRecord.file_handle) {
|
|
throw error;
|
|
}
|
|
|
|
delete storedRecord.file_handle;
|
|
await withStore("readwrite", (store) => store.put(storedRecord));
|
|
log("Saved resume state without a reusable file handle.");
|
|
}
|
|
|
|
await loadRecords();
|
|
return storedRecord;
|
|
}
|
|
|
|
async function deleteRecord(uploadId) {
|
|
await withStore("readwrite", (store) => store.delete(uploadId));
|
|
await loadRecords();
|
|
}
|
|
|
|
function renderPendingRecords() {
|
|
pendingList.replaceChildren();
|
|
|
|
const activeUploadIds = new Set(
|
|
state.uploadItems
|
|
.map((item) => item.record?.upload_id)
|
|
.filter((uploadId) => Boolean(uploadId)),
|
|
);
|
|
const visibleRecords = state.pendingRecords.filter(
|
|
(record) => !activeUploadIds.has(record.upload_id),
|
|
);
|
|
|
|
pendingSection.hidden = visibleRecords.length === 0;
|
|
|
|
for (const record of visibleRecords) {
|
|
const item = document.createElement("li");
|
|
item.className = "pending-item";
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "pending-meta";
|
|
|
|
const title = document.createElement("strong");
|
|
title.textContent = record.name;
|
|
|
|
const detail = document.createElement("span");
|
|
detail.textContent = savedUploadDetail(record);
|
|
|
|
const resume = document.createElement("button");
|
|
resume.type = "button";
|
|
resume.className = "secondary";
|
|
resume.textContent = isReadyToFinish(record) ? "Finish" : "Resume";
|
|
resume.disabled = state.schedulerRunning || !hasAvailableFileSlot();
|
|
resume.addEventListener("click", () => {
|
|
void resumePendingRecord(record);
|
|
});
|
|
|
|
const remove = document.createElement("button");
|
|
remove.type = "button";
|
|
remove.className = "danger";
|
|
remove.textContent = "Clear";
|
|
remove.disabled = state.schedulerRunning;
|
|
remove.addEventListener("click", () => {
|
|
void deleteRecord(record.upload_id);
|
|
});
|
|
|
|
meta.append(title, detail);
|
|
item.append(meta, resume, remove);
|
|
pendingList.append(item);
|
|
}
|
|
}
|
|
|
|
function renderUploadItems() {
|
|
uploadList.replaceChildren();
|
|
uploadSection.hidden = state.uploadItems.length === 0;
|
|
|
|
for (const item of state.uploadItems) {
|
|
const row = document.createElement("li");
|
|
row.className = "upload-item";
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "upload-item-header";
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "upload-meta";
|
|
|
|
const title = document.createElement("strong");
|
|
title.textContent = item.file.name;
|
|
|
|
const detail = document.createElement("span");
|
|
detail.textContent = uploadItemDetail(item);
|
|
|
|
meta.append(title, detail);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "upload-item-actions";
|
|
|
|
const start = document.createElement("button");
|
|
start.type = "button";
|
|
start.textContent = uploadActionLabel(item);
|
|
start.disabled =
|
|
!canRunItem(item) || state.schedulerRunning || !hasAvailableFileSlot();
|
|
start.addEventListener("click", () => {
|
|
void runUploadItem(item);
|
|
});
|
|
|
|
const pause = document.createElement("button");
|
|
pause.type = "button";
|
|
pause.className = "secondary";
|
|
pause.textContent = "Pause";
|
|
pause.disabled = !item.running;
|
|
pause.addEventListener("click", () => {
|
|
item.abortController?.abort();
|
|
});
|
|
|
|
const remove = document.createElement("button");
|
|
remove.type = "button";
|
|
remove.className = "secondary";
|
|
remove.textContent = "Remove";
|
|
remove.disabled = item.running || item.queued;
|
|
remove.addEventListener("click", () => {
|
|
removeUploadItem(item);
|
|
});
|
|
|
|
actions.append(start, pause, remove);
|
|
header.append(meta, actions);
|
|
|
|
const progress = document.createElement("div");
|
|
progress.className = "upload-progress";
|
|
|
|
const progressWrap = document.createElement("div");
|
|
progressWrap.className = "progress-wrap";
|
|
progressWrap.setAttribute("aria-label", `${item.file.name} upload progress`);
|
|
|
|
const progressBar = document.createElement("div");
|
|
progressBar.className = "progress-bar";
|
|
progressBar.style.width = `${progressPercentage(
|
|
item.completedCount,
|
|
item.totalChunks,
|
|
)}%`;
|
|
|
|
const progressMeta = document.createElement("div");
|
|
progressMeta.className = "progress-meta";
|
|
progressMeta.textContent = `${item.completedCount} of ${item.totalChunks} chunks`;
|
|
|
|
progressWrap.append(progressBar);
|
|
progress.append(progressWrap, progressMeta);
|
|
row.append(header, progress);
|
|
uploadList.append(row);
|
|
}
|
|
}
|
|
|
|
function renderButtons() {
|
|
const hasRunnable = state.uploadItems.some((item) => canRunItem(item));
|
|
const hasRunnableResume = state.uploadItems.some(
|
|
(item) => item.record && canRunItem(item),
|
|
);
|
|
const hasRunningOrQueued = state.uploadItems.some((item) => item.running || item.queued);
|
|
const hasFileSlot = hasAvailableFileSlot();
|
|
|
|
startButton.disabled = !hasRunnable || state.schedulerRunning || !hasFileSlot;
|
|
pauseButton.disabled = !hasRunningOrQueued;
|
|
resumeButton.disabled = !hasRunnableResume || state.schedulerRunning || !hasFileSlot;
|
|
}
|
|
|
|
function renderAll() {
|
|
renderUploadItems();
|
|
renderPendingRecords();
|
|
renderButtons();
|
|
}
|
|
|
|
function progressPercentage(completedCount, totalChunks) {
|
|
if (totalChunks <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.min(100, Math.max(0, (completedCount / totalChunks) * 100));
|
|
}
|
|
|
|
function sameFile(record, file) {
|
|
return (
|
|
record.name === file.name &&
|
|
record.size === file.size &&
|
|
record.last_modified === file.lastModified
|
|
);
|
|
}
|
|
|
|
function sameUploadItemFile(item, file) {
|
|
return (
|
|
item.file.name === file.name &&
|
|
item.file.size === file.size &&
|
|
item.file.lastModified === file.lastModified
|
|
);
|
|
}
|
|
|
|
function findPendingRecord(file) {
|
|
return state.pendingRecords.find((record) => sameFile(record, file)) ?? null;
|
|
}
|
|
|
|
function findUploadItem(file, record = null) {
|
|
return (
|
|
state.uploadItems.find((item) => {
|
|
if (item.finished || item.terminal) {
|
|
return false;
|
|
}
|
|
|
|
if (record?.upload_id && item.record?.upload_id === record.upload_id) {
|
|
return true;
|
|
}
|
|
|
|
return sameUploadItemFile(item, file);
|
|
}) ?? null
|
|
);
|
|
}
|
|
|
|
function completedChunkCount(record) {
|
|
return Math.min(record.completed_chunks ?? 0, record.total_chunks ?? 0);
|
|
}
|
|
|
|
function isReadyToFinish(record) {
|
|
const totalChunks = record.total_chunks ?? 0;
|
|
return totalChunks === 0 || completedChunkCount(record) >= totalChunks;
|
|
}
|
|
|
|
function isUploadItemReadyToFinish(item) {
|
|
return (
|
|
Boolean(item.record) &&
|
|
(item.totalChunks === 0 || item.completedCount >= item.totalChunks)
|
|
);
|
|
}
|
|
|
|
function savedUploadDetail(record) {
|
|
const totalChunks = record.total_chunks ?? 0;
|
|
const completedChunks = completedChunkCount(record);
|
|
|
|
if (isReadyToFinish(record)) {
|
|
return `${formatBytes(record.size)} - ready to finish`;
|
|
}
|
|
|
|
if (completedChunks === 0) {
|
|
return `${formatBytes(record.size)} - not uploaded yet`;
|
|
}
|
|
|
|
return `${formatBytes(record.size)} - ${completedChunks} of ${totalChunks} chunks uploaded`;
|
|
}
|
|
|
|
function uploadItemDetail(item) {
|
|
return `${formatBytes(item.file.size)} - ${item.statusText}`;
|
|
}
|
|
|
|
function uploadActionLabel(item) {
|
|
if (item.finished) {
|
|
return "Done";
|
|
}
|
|
|
|
if (!item.record) {
|
|
return "Start";
|
|
}
|
|
|
|
return isUploadItemReadyToFinish(item) ? "Finish" : "Resume";
|
|
}
|
|
|
|
function initialUploadStatus(record) {
|
|
if (!record) {
|
|
return "Ready to create an upload record.";
|
|
}
|
|
|
|
if (isReadyToFinish(record)) {
|
|
return "Ready to finish saved upload.";
|
|
}
|
|
|
|
return "Ready to resume upload.";
|
|
}
|
|
|
|
function canRunItem(item) {
|
|
return (
|
|
Boolean(item.file) &&
|
|
!item.running &&
|
|
!item.queued &&
|
|
!item.finished &&
|
|
!item.terminal
|
|
);
|
|
}
|
|
|
|
function runningFileCount() {
|
|
return state.uploadItems.filter((item) => item.running).length;
|
|
}
|
|
|
|
function hasAvailableFileSlot() {
|
|
return runningFileCount() < FILE_CONCURRENCY;
|
|
}
|
|
|
|
function setItemProgress(item, completedCount, totalChunks) {
|
|
item.totalChunks = Math.max(0, totalChunks);
|
|
item.completedCount =
|
|
item.totalChunks === 0
|
|
? 0
|
|
: Math.min(Math.max(0, completedCount), item.totalChunks);
|
|
}
|
|
|
|
async function selectFiles(files, fileHandles = []) {
|
|
const selectedFiles = Array.from(files);
|
|
if (selectedFiles.length === 0) {
|
|
log("Choose files to begin.");
|
|
renderButtons();
|
|
return;
|
|
}
|
|
|
|
const resumeRecord = state.resumeAfterReselect;
|
|
let matchedResumeRecord = false;
|
|
let addedCount = 0;
|
|
|
|
for (const [index, file] of selectedFiles.entries()) {
|
|
let record = findPendingRecord(file);
|
|
if (resumeRecord && sameFile(resumeRecord, file)) {
|
|
record = resumeRecord;
|
|
matchedResumeRecord = true;
|
|
}
|
|
|
|
const previousCount = state.uploadItems.length;
|
|
addUploadItem(file, fileHandles[index] ?? null, record);
|
|
if (state.uploadItems.length > previousCount) {
|
|
addedCount += 1;
|
|
}
|
|
}
|
|
|
|
if (resumeRecord && !matchedResumeRecord) {
|
|
log("Selected files did not include the pending upload file.");
|
|
}
|
|
|
|
state.resumeAfterReselect = null;
|
|
fileInput.value = "";
|
|
renderAll();
|
|
|
|
if (addedCount === 1) {
|
|
log("Ready to upload 1 file.");
|
|
} else if (addedCount > 1) {
|
|
log(`Ready to upload ${addedCount} files.`);
|
|
}
|
|
}
|
|
|
|
function addUploadItem(file, fileHandle = null, record = null) {
|
|
if (record && !sameFile(record, file)) {
|
|
log("Selected file does not match the pending upload.");
|
|
return null;
|
|
}
|
|
|
|
const existingItem = findUploadItem(file, record);
|
|
if (existingItem) {
|
|
log(`${file.name} is already selected.`);
|
|
return existingItem;
|
|
}
|
|
|
|
const item = {
|
|
abortController: null,
|
|
completedChunks: new Set(),
|
|
completedCount: record ? completedChunkCount(record) : 0,
|
|
file,
|
|
fileHandle,
|
|
finished: false,
|
|
id: nextUploadItemId,
|
|
queued: false,
|
|
record,
|
|
running: false,
|
|
statusText: initialUploadStatus(record),
|
|
terminal: false,
|
|
totalChunks: record?.total_chunks ?? 0,
|
|
};
|
|
|
|
nextUploadItemId += 1;
|
|
state.uploadItems.push(item);
|
|
return item;
|
|
}
|
|
|
|
function removeUploadItem(item) {
|
|
if (item.running || item.queued) {
|
|
return;
|
|
}
|
|
|
|
state.uploadItems = state.uploadItems.filter((candidate) => candidate.id !== item.id);
|
|
renderAll();
|
|
}
|
|
|
|
async function pickFile() {
|
|
if ("showOpenFilePicker" in window) {
|
|
try {
|
|
const handles = await window.showOpenFilePicker({ multiple: true });
|
|
const files = await Promise.all(handles.map((handle) => handle.getFile()));
|
|
await selectFiles(files, handles);
|
|
return;
|
|
} catch (error) {
|
|
if (isAbortError(error)) {
|
|
return;
|
|
}
|
|
log(`File picker failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
fileInput.click();
|
|
}
|
|
|
|
fileInput.addEventListener("change", () => {
|
|
void selectFiles(fileInput.files);
|
|
});
|
|
|
|
pickButton.addEventListener("click", () => {
|
|
void pickFile();
|
|
});
|
|
|
|
startButton.addEventListener("click", () => {
|
|
void runUploadItems(state.uploadItems);
|
|
});
|
|
|
|
pauseButton.addEventListener("click", () => {
|
|
pauseUploads();
|
|
});
|
|
|
|
resumeButton.addEventListener("click", () => {
|
|
void runUploadItems(state.uploadItems.filter((item) => item.record));
|
|
});
|
|
|
|
async function resumePendingRecord(record) {
|
|
if (state.schedulerRunning) {
|
|
return;
|
|
}
|
|
|
|
if (record.file_handle) {
|
|
const granted = await requestFileHandlePermission(record.file_handle);
|
|
if (granted) {
|
|
const file = await record.file_handle.getFile();
|
|
const item = addUploadItem(file, record.file_handle, record);
|
|
renderAll();
|
|
if (item) {
|
|
await runUploadItem(item);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
state.resumeAfterReselect = record;
|
|
log(
|
|
isReadyToFinish(record)
|
|
? "Select the same file to finish."
|
|
: "Select the same file to resume.",
|
|
);
|
|
fileInput.click();
|
|
}
|
|
|
|
async function requestFileHandlePermission(handle) {
|
|
if (!("queryPermission" in handle) || !("requestPermission" in handle)) {
|
|
return false;
|
|
}
|
|
|
|
const options = { mode: "read" };
|
|
if ((await handle.queryPermission(options)) === "granted") {
|
|
return true;
|
|
}
|
|
|
|
return (await handle.requestPermission(options)) === "granted";
|
|
}
|
|
|
|
async function runUploadItems(items) {
|
|
if (state.schedulerRunning) {
|
|
return;
|
|
}
|
|
|
|
const runnableItems = items.filter((item) => canRunItem(item));
|
|
const availableSlots = FILE_CONCURRENCY - runningFileCount();
|
|
if (runnableItems.length === 0 || availableSlots <= 0) {
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
state.schedulerAbortController = controller;
|
|
state.schedulerRunning = true;
|
|
|
|
for (const item of runnableItems) {
|
|
item.queued = true;
|
|
item.statusText = "Queued.";
|
|
}
|
|
renderAll();
|
|
|
|
try {
|
|
await runPool(
|
|
runnableItems,
|
|
async (item) => {
|
|
throwIfAborted(controller.signal);
|
|
if (!item.queued) {
|
|
return;
|
|
}
|
|
await runUploadItem(item);
|
|
},
|
|
availableSlots,
|
|
controller.signal,
|
|
);
|
|
} catch (error) {
|
|
if (!isAbortError(error)) {
|
|
log(`Upload scheduler failed: ${error.message}`);
|
|
}
|
|
} finally {
|
|
for (const item of runnableItems) {
|
|
if (item.queued) {
|
|
item.queued = false;
|
|
item.statusText = "Paused.";
|
|
}
|
|
}
|
|
|
|
if (state.schedulerAbortController === controller) {
|
|
state.schedulerAbortController = null;
|
|
}
|
|
state.schedulerRunning = false;
|
|
renderAll();
|
|
}
|
|
}
|
|
|
|
function pauseUploads() {
|
|
state.schedulerAbortController?.abort();
|
|
|
|
for (const item of state.uploadItems) {
|
|
if (item.running) {
|
|
item.abortController?.abort();
|
|
} else if (item.queued) {
|
|
item.queued = false;
|
|
item.statusText = "Paused.";
|
|
}
|
|
}
|
|
|
|
renderAll();
|
|
}
|
|
|
|
async function runUploadItem(item) {
|
|
if (
|
|
item.running ||
|
|
item.finished ||
|
|
item.terminal ||
|
|
(!item.queued && !hasAvailableFileSlot()) ||
|
|
(!item.queued && !canRunItem(item))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
item.abortController = controller;
|
|
item.queued = false;
|
|
item.running = true;
|
|
item.statusText = item.record
|
|
? "Checking saved progress."
|
|
: "Creating upload record.";
|
|
renderAll();
|
|
|
|
try {
|
|
if (!item.record) {
|
|
await createUploadRecord(item, controller.signal);
|
|
}
|
|
|
|
const progress = await fetchJson(`/api/uploads/${item.record.upload_id}`, {
|
|
signal: controller.signal,
|
|
});
|
|
item.completedChunks = new Set(progress.completed_chunks);
|
|
setItemProgress(item, item.completedChunks.size, progress.total_chunks);
|
|
item.record = await saveRecord({
|
|
...item.record,
|
|
completed_chunks: item.completedCount,
|
|
chunk_size: progress.chunk_size,
|
|
total_chunks: progress.total_chunks,
|
|
});
|
|
renderAll();
|
|
|
|
const missingChunks = buildMissingChunkList(progress.total_chunks, item.completedChunks);
|
|
item.statusText =
|
|
missingChunks.length === 0
|
|
? "All chunks already uploaded."
|
|
: `Uploading ${missingChunks.length} missing chunks.`;
|
|
renderAll();
|
|
|
|
await runPool(
|
|
missingChunks,
|
|
(index) =>
|
|
uploadChunkWithRetry(
|
|
item,
|
|
index,
|
|
progress.chunk_size,
|
|
progress.total_chunks,
|
|
controller.signal,
|
|
),
|
|
CHUNK_CONCURRENCY,
|
|
controller.signal,
|
|
);
|
|
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
item.statusText = "Completing upload.";
|
|
renderAll();
|
|
const uploadId = item.record.upload_id;
|
|
const complete = await fetchJson(`/api/uploads/${uploadId}/complete`, {
|
|
method: "POST",
|
|
signal: controller.signal,
|
|
});
|
|
|
|
setItemProgress(item, progress.total_chunks, progress.total_chunks);
|
|
item.finished = true;
|
|
item.statusText = `Complete: ${complete.file_path}`;
|
|
await deleteRecord(uploadId);
|
|
} catch (error) {
|
|
if (controller.signal.aborted || isAbortError(error)) {
|
|
item.statusText = "Paused.";
|
|
} else if (await handleTerminalUploadError(item, error)) {
|
|
controller.abort();
|
|
} else {
|
|
controller.abort();
|
|
item.statusText = `Upload failed: ${error.message}`;
|
|
}
|
|
} finally {
|
|
if (item.abortController === controller) {
|
|
item.abortController = null;
|
|
}
|
|
item.running = false;
|
|
renderAll();
|
|
}
|
|
}
|
|
|
|
async function handleTerminalUploadError(item, error) {
|
|
if (!item.record || typeof error.status !== "number") {
|
|
return false;
|
|
}
|
|
|
|
const uploadId = item.record.upload_id;
|
|
|
|
if (error.status === 404) {
|
|
await deleteRecord(uploadId);
|
|
item.record = null;
|
|
item.completedChunks = new Set();
|
|
setItemProgress(item, 0, 0);
|
|
item.statusText = "Saved upload progress no longer exists. Start again.";
|
|
log(`${item.file.name}: saved upload progress no longer exists on the server.`);
|
|
return true;
|
|
}
|
|
|
|
if (error.status === 409 && error.message === COMPLETE_EXISTS_MESSAGE) {
|
|
await deleteRecord(uploadId);
|
|
item.terminal = true;
|
|
item.statusText = "Complete file already exists on the server.";
|
|
log(`${item.file.name}: complete file already exists on the server.`);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function createUploadRecord(item, signal) {
|
|
const response = await fetchJson("/api/uploads", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: item.file.name,
|
|
size: item.file.size,
|
|
last_modified: item.file.lastModified,
|
|
}),
|
|
signal,
|
|
});
|
|
|
|
const record = {
|
|
upload_id: response.upload_id,
|
|
name: item.file.name,
|
|
size: item.file.size,
|
|
last_modified: item.file.lastModified,
|
|
chunk_size: response.chunk_size,
|
|
total_chunks: response.total_chunks,
|
|
completed_chunks: 0,
|
|
file_handle: item.fileHandle,
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
item.record = await saveRecord(record);
|
|
setItemProgress(item, 0, response.total_chunks);
|
|
item.statusText = "Upload record created.";
|
|
renderAll();
|
|
}
|
|
|
|
function buildMissingChunkList(totalChunks, completedChunks) {
|
|
const missing = [];
|
|
for (let index = 0; index < totalChunks; index += 1) {
|
|
if (!completedChunks.has(index)) {
|
|
missing.push(index);
|
|
}
|
|
}
|
|
return missing;
|
|
}
|
|
|
|
async function uploadChunkWithRetry(item, index, chunkSize, totalChunks, signal) {
|
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
|
|
throwIfAborted(signal);
|
|
|
|
try {
|
|
await uploadChunk(item, index, chunkSize, signal);
|
|
item.completedChunks.add(index);
|
|
setItemProgress(item, item.completedChunks.size, totalChunks);
|
|
item.record = await saveRecord({
|
|
...item.record,
|
|
completed_chunks: item.completedCount,
|
|
});
|
|
renderAll();
|
|
return;
|
|
} catch (error) {
|
|
if (isAbortError(error) || attempt === MAX_RETRIES) {
|
|
throw error;
|
|
}
|
|
|
|
const delayMs = BASE_RETRY_DELAY_MS * 2 ** attempt;
|
|
item.statusText = `Retrying chunk ${index} after ${delayMs} ms.`;
|
|
renderAll();
|
|
await delay(delayMs, signal);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function uploadChunk(item, index, chunkSize, signal) {
|
|
const start = index * chunkSize;
|
|
const end = Math.min(item.file.size, start + chunkSize);
|
|
const body = item.file.slice(start, end);
|
|
const response = await fetch(
|
|
`/api/uploads/${item.record.upload_id}/chunks/${index}`,
|
|
{
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
body,
|
|
signal,
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new ApiRequestError(await responseError(response), response.status);
|
|
}
|
|
}
|
|
|
|
async function runPool(items, worker, concurrency, signal = null) {
|
|
let nextIndex = 0;
|
|
const workers = Array.from(
|
|
{ length: Math.min(concurrency, items.length) },
|
|
async () => {
|
|
while (nextIndex < items.length) {
|
|
if (signal) {
|
|
throwIfAborted(signal);
|
|
}
|
|
|
|
const item = items[nextIndex];
|
|
nextIndex += 1;
|
|
await worker(item);
|
|
}
|
|
},
|
|
);
|
|
|
|
await Promise.all(workers);
|
|
}
|
|
|
|
class ApiRequestError extends Error {
|
|
constructor(message, status) {
|
|
super(message);
|
|
this.name = "ApiRequestError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
async function fetchJson(url, options = {}) {
|
|
const response = await fetch(url, options);
|
|
if (!response.ok) {
|
|
throw new ApiRequestError(await responseError(response), response.status);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function responseError(response) {
|
|
const text = await response.text();
|
|
if (!text) {
|
|
return `${response.status} ${response.statusText}`;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text).error ?? text;
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
function delay(ms, signal) {
|
|
return new Promise((resolve, reject) => {
|
|
throwIfAborted(signal);
|
|
|
|
const id = window.setTimeout(resolve, ms);
|
|
signal.addEventListener(
|
|
"abort",
|
|
() => {
|
|
window.clearTimeout(id);
|
|
reject(new DOMException("Aborted", "AbortError"));
|
|
},
|
|
{ once: true },
|
|
);
|
|
});
|
|
}
|
|
|
|
function throwIfAborted(signal) {
|
|
if (signal.aborted) {
|
|
throw new DOMException("Aborted", "AbortError");
|
|
}
|
|
}
|
|
|
|
function isAbortError(error) {
|
|
return error?.name === "AbortError";
|
|
}
|
|
|
|
async function initialize() {
|
|
await loadRecords();
|
|
renderAll();
|
|
}
|
|
|
|
void initialize();
|