feat: implement resumable chunked media upload with progress indicators in admin UI
This commit is contained in:
1 parent
930cdfdff9
commit
2c06265d11
6 files changed
+926
-14
No files matched your search
+177
-8
@@ -33,12 +33,84 @@ const adminConfig = {
|
||||
: 50,
|
||||
};
|
||||
|
||||
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
const UPLOAD_RESUME_KEY_PREFIX = "linearvoid-upload-resume:";
|
||||
|
||||
const state = {
|
||||
mediaUploading: false,
|
||||
moderationSubmitting: false,
|
||||
playlistSaving: false,
|
||||
};
|
||||
|
||||
const uploadProgressWrap = () =>
|
||||
document.getElementById("media-upload-progress-wrap") as HTMLElement | null;
|
||||
|
||||
const uploadProgressBar = () =>
|
||||
document.getElementById("media-upload-progress") as HTMLProgressElement | null;
|
||||
|
||||
const uploadProgressText = () =>
|
||||
document.getElementById("media-upload-progress-text") as HTMLElement | null;
|
||||
|
||||
const setUploadProgress = (value: number, total: number, label: string) => {
|
||||
const wrap = uploadProgressWrap();
|
||||
const progress = uploadProgressBar();
|
||||
const text = uploadProgressText();
|
||||
if (!wrap || !progress || !text) return;
|
||||
|
||||
wrap.hidden = false;
|
||||
progress.max = total;
|
||||
progress.value = value;
|
||||
|
||||
const pct = total > 0 ? Math.min(100, Math.round((value / total) * 100)) : 0;
|
||||
text.textContent = `${label} (${pct}%)`;
|
||||
};
|
||||
|
||||
const hideUploadProgress = () => {
|
||||
const wrap = uploadProgressWrap();
|
||||
if (wrap) wrap.hidden = true;
|
||||
};
|
||||
|
||||
const getFileFingerprint = (file: File) =>
|
||||
`${file.name}:${file.size}:${file.lastModified}`;
|
||||
|
||||
const getResumeStorageKey = (fingerprint: string) =>
|
||||
`${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`;
|
||||
|
||||
const getStoredUploadId = (fingerprint: string) => {
|
||||
try {
|
||||
return window.localStorage.getItem(getResumeStorageKey(fingerprint)) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const setStoredUploadId = (fingerprint: string, uploadId: string) => {
|
||||
try {
|
||||
window.localStorage.setItem(getResumeStorageKey(fingerprint), uploadId);
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
};
|
||||
|
||||
const clearStoredUploadId = (fingerprint: string) => {
|
||||
try {
|
||||
window.localStorage.removeItem(getResumeStorageKey(fingerprint));
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
};
|
||||
|
||||
const readErrorMessage = async (response: Response) => {
|
||||
const fallback = `HTTP ${response.status}`;
|
||||
try {
|
||||
const text = (await response.text()).trim();
|
||||
return text || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const setText = (id: string, text: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
@@ -330,30 +402,110 @@ async function refreshMediaLibrary() {
|
||||
|
||||
async function uploadVideo(file: File) {
|
||||
if (state.mediaUploading) return;
|
||||
|
||||
if (file.size <= 0) {
|
||||
setStatus("media-status", "Cannot upload an empty file.", true);
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
setStatus("media-status", "File exceeds 2GB upload limit.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
state.mediaUploading = true;
|
||||
setStatus("media-status", `Uploading ${file.name}...`);
|
||||
setStatus("media-status", `Preparing upload for ${file.name}...`);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const totalChunks = Math.ceil(file.size / UPLOAD_CHUNK_BYTES);
|
||||
const fingerprint = getFileFingerprint(file);
|
||||
|
||||
const response = await fetch("/api/admin/media", {
|
||||
const initResponse = await fetch("/api/admin/media/uploads", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
uploadId: getStoredUploadId(fingerprint),
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
chunkSize: UPLOAD_CHUNK_BYTES,
|
||||
totalChunks,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
if (!initResponse.ok) {
|
||||
throw new Error(await readErrorMessage(initResponse));
|
||||
}
|
||||
|
||||
const initPayload = await initResponse.json();
|
||||
const uploadId = String(initPayload.uploadId || "");
|
||||
if (!uploadId) {
|
||||
throw new Error("Upload session could not be initialized.");
|
||||
}
|
||||
|
||||
setStoredUploadId(fingerprint, uploadId);
|
||||
|
||||
const alreadyUploaded = Number(initPayload.uploadedChunks || 0);
|
||||
const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks);
|
||||
const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks);
|
||||
|
||||
setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks");
|
||||
|
||||
for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) {
|
||||
const start = chunkIndex * UPLOAD_CHUNK_BYTES;
|
||||
const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const chunkResponse = await fetch(
|
||||
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/chunk?index=${chunkIndex}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: chunk,
|
||||
},
|
||||
);
|
||||
|
||||
if (!chunkResponse.ok) {
|
||||
throw new Error(await readErrorMessage(chunkResponse));
|
||||
}
|
||||
|
||||
setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks");
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`,
|
||||
);
|
||||
}
|
||||
|
||||
const completeResponse = await fetch(
|
||||
`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(await readErrorMessage(completeResponse));
|
||||
}
|
||||
|
||||
setUploadProgress(effectiveTotalChunks, effectiveTotalChunks, "Finalizing");
|
||||
|
||||
const payload = await completeResponse.json();
|
||||
renderMediaRows(payload.files || []);
|
||||
setText("media-generated-at", payload.generatedAt || "unavailable");
|
||||
setStatus("media-status", `${file.name} uploaded.`);
|
||||
clearStoredUploadId(fingerprint);
|
||||
await refreshOverview();
|
||||
await refreshSchedule();
|
||||
} catch (error) {
|
||||
console.error("Failed to upload media", error);
|
||||
setStatus("media-status", "Failed to upload media file.", true);
|
||||
const details = error instanceof Error ? error.message : "Unknown upload error.";
|
||||
setStatus(
|
||||
"media-status",
|
||||
`Upload interrupted. Re-select the same file to resume. (${details})`,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
state.mediaUploading = false;
|
||||
setTimeout(() => hideUploadProgress(), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,9 +717,26 @@ function bindEvents() {
|
||||
const uploadInput = document.getElementById(
|
||||
"video-upload",
|
||||
) as HTMLInputElement | null;
|
||||
const uploadDropzone = document.getElementById("upload-dropzone");
|
||||
document
|
||||
.getElementById("upload-trigger-btn")
|
||||
?.addEventListener("click", () => uploadInput?.click());
|
||||
|
||||
uploadDropzone?.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.add("is-dragover");
|
||||
});
|
||||
uploadDropzone?.addEventListener("dragleave", () => {
|
||||
uploadDropzone.classList.remove("is-dragover");
|
||||
});
|
||||
uploadDropzone?.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
uploadDropzone.classList.remove("is-dragover");
|
||||
const droppedFiles = event.dataTransfer?.files;
|
||||
if (!droppedFiles || droppedFiles.length === 0) return;
|
||||
await uploadVideo(droppedFiles[0]);
|
||||
});
|
||||
|
||||
uploadInput?.addEventListener("change", async (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
|
||||
Reference in new issue
Block a user