feat: implement resumable chunked media upload with progress indicators in admin UI
CI / test (push) Successful in 1m38s
CI / docker (push) Successful in 3m11s
CI / release (push) Skipped

This commit is contained in:
zwitschi committed 2026-08-04 20:21:53 +02:00
1 parent 930cdfdff9
commit 2c06265d11
6 files changed
+926 -14

No files matched your search

+6 -1
View File
@@ -64,9 +64,14 @@
<button id="reload-media-btn" type="button" class="btn btn-secondary">Reload library</button>
</div>
<div class="upload-dropzone">
<div id="upload-dropzone" class="upload-dropzone">
<input type="file" id="video-upload" accept="video/*,.mp4,.mov,.mkv,.webm" hidden />
<button id="upload-trigger-btn" type="button" class="btn btn-primary btn-large">Drag and Drop or Click to Upload</button>
<p class="small-muted">Resumable chunk upload enabled • up to 2GB per file.</p>
<div id="media-upload-progress-wrap" class="upload-progress" hidden>
<progress id="media-upload-progress" value="0" max="100"></progress>
<p id="media-upload-progress-text" class="small-muted">Uploading (0%)</p>
</div>
<p id="media-status" class="status-line" aria-live="polite">Loading media library...</p>
</div>
+177 -8
View File
@@ -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;
+16
View File
@@ -645,6 +645,22 @@ select {
gap: var(--space-2);
}
.upload-dropzone.is-dragover {
border-color: var(--brand);
background: #e6f6ef;
}
.upload-progress {
display: grid;
gap: var(--space-1);
width: min(540px, 100%);
}
.upload-progress progress {
width: 100%;
height: 0.75rem;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));