Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ docs/ Repository-level technical documentation
- Sensitive areas are checked server-side against the backend session and reinforced client-side with sanitized callback URLs.
- Protected routes distinguish between an expired session and a temporarily unavailable auth backend, redirecting the latter to a dedicated recovery screen instead of treating it as a logout.
- Client-side auth hydration preserves a distinct "service unavailable" state so public surfaces do not misleadingly fall back to login prompts when the auth backend is down.
- Uploads are sent as multipart form data to the backend.
- Large video uploads automatically use the backend chunked-upload flow instead of a single monolithic request.

For a more detailed technical walkthrough, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).

Expand Down
77 changes: 70 additions & 7 deletions app/upload/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
Expand All @@ -15,7 +15,15 @@ import ThumbnailDropzone from "./components/thumbnail-dropzone";
import UploadProgress from "./components/upload-progress";
import UploadDetailsFormFields from "./components/details-fields";
import UploadStepScreen from "./components/upload-step";
import { UPLOAD_STEPS, type UploadStep } from "./upload-constants";
import {
DEFAULT_UPLOADING_LABEL,
MAX_THUMBNAIL_BYTES,
MAX_THUMBNAIL_MB,
MAX_VIDEO_UPLOAD_TOTAL_BYTES,
MAX_VIDEO_UPLOAD_TOTAL_MB,
UPLOAD_STEPS,
type UploadStep,
} from "./upload-constants";
import { uploadVideo } from "./upload-api";
import {
createIdleUploadRequestState,
Expand Down Expand Up @@ -50,6 +58,7 @@ export default function UploadPage() {
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailError, setThumbnailError] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState<UploadStep>(1);
const uploadAbortControllerRef = useRef<AbortController | null>(null);

const {
register,
Expand Down Expand Up @@ -82,6 +91,13 @@ export default function UploadPage() {
reset();
};

useEffect(() => {
return () => {
uploadAbortControllerRef.current?.abort();
uploadAbortControllerRef.current = null;
};
}, []);

const handleFileSelect = (nextFile: File | null) => {
resetUploadSession();
setCurrentStep(1);
Expand All @@ -98,6 +114,12 @@ export default function UploadPage() {
return;
}

if (nextFile.size > MAX_VIDEO_UPLOAD_TOTAL_BYTES) {
setFile(null);
setFileError(`Videos are limited to ${MAX_VIDEO_UPLOAD_TOTAL_MB} MB.`);
return;
}

setFile(nextFile);
setFileError(null);
setCurrentStep(2);
Expand All @@ -116,6 +138,12 @@ export default function UploadPage() {
return;
}

if (nextFile.size > MAX_THUMBNAIL_BYTES) {
setThumbnailFile(null);
setThumbnailError(`Thumbnails are limited to ${MAX_THUMBNAIL_MB} MB.`);
return;
}

setThumbnailFile(nextFile);
setThumbnailError(null);
};
Expand All @@ -128,19 +156,37 @@ export default function UploadPage() {
}

setCurrentStep(4);
setVideoRequest({ state: "uploading", progress: 0, error: null });
setVideoRequest({
state: "uploading",
progress: 0,
error: null,
uploadingLabel: DEFAULT_UPLOADING_LABEL,
});

try {
const controller = new AbortController();
uploadAbortControllerRef.current = controller;

await uploadVideo({
file,
thumbnail: thumbnailFile,
values: data,
onProgress: (progress) => {
setVideoRequest((prev) => ({ ...prev, progress }));
signal: controller.signal,
onProgress: ({ progress, label }) => {
setVideoRequest((prev) => ({
...prev,
progress,
uploadingLabel: label,
}));
},
});

setVideoRequest({ state: "done", progress: 100, error: null });
setVideoRequest({
state: "done",
progress: 100,
error: null,
uploadingLabel: DEFAULT_UPLOADING_LABEL,
});

toast.success(
thumbnailFile
Expand All @@ -149,8 +195,15 @@ export default function UploadPage() {
);
} catch (error) {
const message = resolveUploadErrorMessage(error);
setVideoRequest((prev) => ({ ...prev, state: "error", error: message }));
setVideoRequest((prev) => ({
...prev,
state: "error",
error: message,
uploadingLabel: DEFAULT_UPLOADING_LABEL,
}));
toast.error(message);
} finally {
uploadAbortControllerRef.current = null;
}
};

Expand Down Expand Up @@ -277,6 +330,16 @@ export default function UploadPage() {
case 4:
return (
<>
{isUploading && (
<Button
type="button"
variant="ghost"
className={secondaryActionClassName}
onClick={() => uploadAbortControllerRef.current?.abort()}
>
Cancel upload
</Button>
)}
{videoRequest.state === "error" && (
<Button
type="button"
Expand Down
197 changes: 165 additions & 32 deletions app/upload/upload-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,57 +12,190 @@ type UploadVideoResponse = {
};
};

type ProgressHandler = (progress: number) => void;
type ChunkedUploadInitResponse = {
uploadId: string;
chunkSizeBytes: number;
chunkSizeMB: number;
totalChunks: number;
totalSize: number;
};

type UploadProgressSnapshot = {
progress: number;
label: string;
};

type ProgressHandler = (snapshot: UploadProgressSnapshot) => void;

const INIT_UPLOAD_TIMEOUT_MS = 30 * 1000;
const CHUNK_UPLOAD_TIMEOUT_MS = 15 * 60 * 1000;
const COMPLETE_UPLOAD_TIMEOUT_MS = 20 * 60 * 1000;

const PREPARING_PROGRESS = 2;
const CHUNK_PROGRESS_START = 5;
const CHUNK_PROGRESS_END = 95;
const FINALIZING_PROGRESS = 98;

const emitProgress = (
onProgress: ProgressHandler | undefined,
progress: number,
label: string,
) => {
onProgress?.({
progress: clampPercentage(Math.round(progress)),
label,
});
};

const buildInitPayload = (file: File, values: UploadFormValues) => {
const payload: Record<string, string | number | boolean> = {
title: values.title.trim(),
allowComments: values.allowComments ?? true,
license: values.license ?? "all_rights_reserved",
totalSize: file.size,
originalName: file.name,
};

if (file.type) {
payload.mimeType = file.type;
}

if (values.description) {
const description = values.description.trim();
if (description.length > 0) {
payload.description = description;
}
}

if (values.tags) {
const normalizedTags = normalizeTags(values.tags).join(",");
if (normalizedTags.length > 0) {
payload.tags = normalizedTags;
}
}

return payload;
};

const getChunkUploadLabel = (chunkIndex: number, totalChunks: number) =>
totalChunks > 1 ? `Uploading chunk ${chunkIndex + 1} of ${totalChunks}` : "Uploading video";

const toPercentage = (event: AxiosProgressEvent) => {
const progressValue = event.progress ?? (event.total ? event.loaded / event.total : 0);
const mapChunkProgress = (uploadedBytes: number, totalBytes: number) => {
const safeTotalBytes = Math.max(totalBytes, 1);
const ratio = Math.max(0, Math.min(1, uploadedBytes / safeTotalBytes));
return CHUNK_PROGRESS_START + ratio * (CHUNK_PROGRESS_END - CHUNK_PROGRESS_START);
};

return clampPercentage(Math.round(progressValue * 100));
const createChunkFormData = (chunk: Blob, chunkIndex: number, fileName: string) => {
const formData = new FormData();
formData.append("chunkIndex", String(chunkIndex));
formData.append("chunk", chunk, `${fileName}.part-${chunkIndex}`);
return formData;
};

const createCompletionFormData = (thumbnail?: File | null) => {
const formData = new FormData();
if (thumbnail) {
formData.append("thumbnail", thumbnail);
}
return formData;
};

const abortChunkedUpload = async (uploadId: string) => {
try {
await api.delete(`/upload/video-chunks/${encodeURIComponent(uploadId)}`, {
timeout: INIT_UPLOAD_TIMEOUT_MS,
});
} catch {
// Best-effort cleanup only.
}
};

const toLoadedBytes = (event: AxiosProgressEvent, chunkSize: number) => {
const loadedBytes = event.loaded ?? 0;
return Math.max(0, Math.min(chunkSize, loadedBytes));
};

export const uploadVideo = async ({
file,
thumbnail,
values,
onProgress,
signal,
}: {
file: File;
thumbnail?: File | null;
values: UploadFormValues;
onProgress?: ProgressHandler;
signal?: AbortSignal;
}) => {
const formData = new FormData();
let uploadId: string | null = null;

formData.append("title", values.title.trim());
formData.append("video", file);
formData.append("allowComments", String(values.allowComments ?? true));
formData.append("license", values.license ?? "all_rights_reserved");
if (thumbnail) {
formData.append("thumbnail", thumbnail);
}
try {
emitProgress(onProgress, PREPARING_PROGRESS, "Preparing upload");

if (values.description) {
const description = values.description.trim();
if (description.length > 0) {
formData.append("description", description);
}
}
const initResponse = await api.post<ChunkedUploadInitResponse>(
"/upload/video-chunks/init",
buildInitPayload(file, values),
{
signal,
timeout: INIT_UPLOAD_TIMEOUT_MS,
},
);

if (values.tags) {
const normalizedTags = normalizeTags(values.tags).join(",");
if (normalizedTags.length > 0) {
formData.append("tags", normalizedTags);
uploadId = initResponse.data.uploadId;
const { chunkSizeBytes, totalChunks } = initResponse.data;

let uploadedBytes = 0;

for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
const start = chunkIndex * chunkSizeBytes;
const end = Math.min(start + chunkSizeBytes, file.size);
const chunk = file.slice(start, end);
const label = getChunkUploadLabel(chunkIndex, totalChunks);

await api.post(
`/upload/video-chunks/${encodeURIComponent(uploadId)}/chunk`,
createChunkFormData(chunk, chunkIndex, file.name),
{
signal,
timeout: CHUNK_UPLOAD_TIMEOUT_MS,
onUploadProgress: (event) => {
const currentChunkBytes = toLoadedBytes(event, chunk.size);
emitProgress(
onProgress,
mapChunkProgress(uploadedBytes + currentChunkBytes, file.size),
label,
);
},
},
);

uploadedBytes += chunk.size;
emitProgress(onProgress, mapChunkProgress(uploadedBytes, file.size), label);
}
}

const response = await api.post<UploadVideoResponse>("/upload/video-bundle", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: (event) => {
onProgress?.(toPercentage(event));
},
});
emitProgress(
onProgress,
FINALIZING_PROGRESS,
thumbnail ? "Uploading thumbnail and finalizing" : "Finalizing upload",
);

const response = await api.post<UploadVideoResponse>(
`/upload/video-chunks/${encodeURIComponent(uploadId)}/complete`,
createCompletionFormData(thumbnail),
{
signal,
timeout: COMPLETE_UPLOAD_TIMEOUT_MS,
},
);

return response.data.video;
return response.data.video;
} catch (error) {
if (uploadId) {
await abortChunkedUpload(uploadId);
}

throw error;
}
};
8 changes: 8 additions & 0 deletions app/upload/upload-constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
export type UploadState = "idle" | "uploading" | "done" | "error";
export type UploadStep = 1 | 2 | 3 | 4;

const MEBIBYTE = 1024 * 1024;

export const MAX_VIDEO_UPLOAD_TOTAL_BYTES = 3040 * MEBIBYTE;
export const MAX_VIDEO_UPLOAD_TOTAL_MB = Math.round(MAX_VIDEO_UPLOAD_TOTAL_BYTES / MEBIBYTE);
export const MAX_THUMBNAIL_BYTES = 5 * MEBIBYTE;
export const MAX_THUMBNAIL_MB = Math.round(MAX_THUMBNAIL_BYTES / MEBIBYTE);
export const DEFAULT_UPLOADING_LABEL = "Uploading video";

export const UPLOAD_STEPS = [
{ id: 1, title: "File" },
{ id: 2, title: "Details" },
Expand Down
Loading
Loading