Skip to content
Open
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
102 changes: 20 additions & 82 deletions package-lock.json

Large diffs are not rendered by default.

460 changes: 460 additions & 0 deletions src/frontend/lib/upload-resume.test.ts

Large diffs are not rendered by default.

417 changes: 417 additions & 0 deletions src/frontend/lib/upload-resume.ts

Large diffs are not rendered by default.

254 changes: 180 additions & 74 deletions src/frontend/pages/Upload.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { FormEvent, useMemo, useState } from 'react';
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../lib/auth-client';
import {
cancelUpload,
chunkCountFor,
clearResumeRecord,
fetchUploadStatus,
fingerprintFile,
fingerprintsMatch,
loadResumeRecord,
saveResumeRecord,
uploadFileInChunks,
UploadAbortedError,
type ResumeRecord,
} from '../lib/upload-resume';

const CHUNK_SIZE = 10 * 1024 * 1024;
const MAX_SIZE = 30 * 1024 * 1024 * 1024;
const ALLOWED_EXTENSIONS = new Set([
'mp4',
Expand All @@ -25,65 +37,6 @@ function isAcceptedVideo(file: File): boolean {
return ALLOWED_EXTENSIONS.has(file.name.slice(dot + 1).toLowerCase());
}

async function uploadInChunks(
file: File,
title: string,
description: string,
onProgress: (value: number) => void,
): Promise<Response> {
const chunkCount = Math.ceil(file.size / CHUNK_SIZE);
let lastResponse: Response | null = null;
let uploadId: string | null = null;

for (let index = 0; index < chunkCount; index += 1) {
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
// Pass file.type so the resulting Blob keeps the parent's MIME — without it
// the chunk's type is '' and the multipart part is sent as
// application/octet-stream, which the upload validator then rejects.
const chunk = file.slice(start, end, file.type);
const formData = new FormData();
formData.set('title', title);
formData.set('description', description);
formData.set('file', chunk, file.name);
formData.set('chunkIndex', String(index));
formData.set('chunkCount', String(chunkCount));
if (uploadId) {
formData.set('uploadId', uploadId);
}

lastResponse = await fetch('/api/videos/upload', {
method: 'POST',
body: formData,
});

if (!lastResponse.ok) {
const body = await lastResponse.text();
let detail = body;
try {
const parsed = JSON.parse(body) as { error?: string; code?: string };
detail = parsed.error ?? body;
if (parsed.code) detail = `${detail} (${parsed.code})`;
} catch {
// Non-JSON response — keep raw text.
}
throw new Error(`Upload failed (${lastResponse.status}): ${detail.slice(0, 300)}`);
}

const responseData = (await lastResponse.json()) as { uploadId?: string };
if (responseData.uploadId) {
uploadId = responseData.uploadId;
}

onProgress(Math.round(((index + 1) / chunkCount) * 100));
}

if (!lastResponse) {
throw new Error('No upload response');
}
return lastResponse;
}

async function resendVerification(): Promise<{ ok: boolean; error: string | null }> {
// ALO-128: ask better-auth to re-issue the verification email. The session
// cookie identifies the user, so the body is empty.
Expand All @@ -109,15 +62,19 @@ async function resendVerification(): Promise<{ ok: boolean; error: string | null
return { ok: false, error: message };
}

type UploadStatus = 'idle' | 'uploading' | 'retrying' | 'offline' | 'paused' | 'done' | 'error';

export function Upload(): JSX.Element {
const { data: session } = useSession();
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [status, setStatus] = useState<UploadStatus>('idle');
const [resendStatus, setResendStatus] = useState<string | null>(null);
const [pendingResume, setPendingResume] = useState<ResumeRecord | null>(null);
const abortRef = useRef<AbortController | null>(null);

const isEmailVerified = session?.user?.emailVerified !== false;
const isValidFile = useMemo(() => {
Expand All @@ -127,10 +84,105 @@ export function Upload(): JSX.Element {
return file.size <= MAX_SIZE && isAcceptedVideo(file);
}, [file]);

// ALO-121: surface a saved resume offer on mount. The user has to re-pick
// the same file — the File API doesn't let us hold a handle across reloads —
// and we verify the fingerprint matches before offering resume.
useEffect(() => {
const stored = loadResumeRecord();
if (stored) {
setPendingResume(stored);
setTitle(stored.title);
setDescription(stored.description);
}
}, []);

const canResumeWithFile = useMemo(() => {
if (!file || !pendingResume) return false;
return fingerprintsMatch(fingerprintFile(file), pendingResume.fingerprint);
}, [file, pendingResume]);

async function runUpload(resume: ResumeRecord | null): Promise<void> {
if (!file) return;
setError(null);
setStatus('uploading');
setProgress(0);

const controller = new AbortController();
abortRef.current = controller;

let uploadIdForResume = resume?.uploadId ?? null;
let skipChunks = new Set<number>();

if (resume) {
try {
const remote = await fetchUploadStatus(resume.uploadId);
if (remote && remote.chunkCount === resume.chunkCount) {
skipChunks = new Set(remote.uploadedChunks);
} else {
// Server forgot the session — start fresh.
uploadIdForResume = null;
clearResumeRecord();
setPendingResume(null);
}
} catch (err) {
// If the status probe fails, fall back to a fresh upload rather
// than getting stuck. The previous multipart in R2 will be cleaned
// up by R2's lifecycle policy.
uploadIdForResume = null;
clearResumeRecord();
setPendingResume(null);
setError(err instanceof Error ? err.message : 'Could not check resume state');
}
}

const chunkCount = chunkCountFor(file.size);
const fingerprint = fingerprintFile(file);

try {
const result = await uploadFileInChunks(
file,
{ title, description },
{
uploadId: uploadIdForResume ?? undefined,
skipChunks,
signal: controller.signal,
},
{
onProgress: (p) => setProgress(Math.round(p.fraction * 100)),
onStatus: (s) => setStatus(s),
onUploadId: (uploadId) => {
const record: ResumeRecord = {
uploadId,
chunkCount,
fingerprint,
title,
description,
createdAt: Date.now(),
};
saveResumeRecord(record);
setPendingResume(record);
},
},
);
clearResumeRecord();
setPendingResume(null);
setStatus('done');
setProgress(100);
void result;
} catch (err: unknown) {
if (err instanceof UploadAbortedError) {
setStatus('paused');
return;
}
setStatus('error');
setError(err instanceof Error ? err.message : 'Upload failed');
} finally {
abortRef.current = null;
}
}

async function onSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setError(null);
setStatus(null);

if (!file) {
setError('Please choose a file');
Expand All @@ -145,12 +197,29 @@ export function Upload(): JSX.Element {
return;
}

try {
await uploadInChunks(file, title, description, setProgress);
setStatus('Upload complete');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Upload failed');
const resume = canResumeWithFile ? pendingResume : null;
await runUpload(resume);
}

async function onCancel(): Promise<void> {
abortRef.current?.abort();
if (pendingResume) {
await cancelUpload(pendingResume.uploadId);
clearResumeRecord();
setPendingResume(null);
}
setStatus('idle');
setProgress(0);
}

function onDiscardResume(): void {
if (pendingResume) {
void cancelUpload(pendingResume.uploadId);
}
clearResumeRecord();
setPendingResume(null);
setTitle('');
setDescription('');
}

return (
Expand Down Expand Up @@ -184,6 +253,26 @@ export function Upload(): JSX.Element {
</div>
) : null}

{pendingResume ? (
<div className="card stack-sm" data-testid="resume-banner">
<strong>You have an unfinished upload.</strong>
<p className="ds-meta">
<code>{pendingResume.fingerprint.name}</code> ({Math.round(
pendingResume.fingerprint.size / (1024 * 1024),
)}{' '}
MB).{' '}
{canResumeWithFile
? 'Re-selecting the same file will resume where you left off.'
: 'Pick the same file to resume, or discard to start over.'}
</p>
<div className="row" style={{ gap: '0.5rem' }}>
<button type="button" className="btn btn--secondary btn--sm" onClick={onDiscardResume}>
Discard unfinished upload
</button>
</div>
</div>
) : null}

<form
onSubmit={(event) => void onSubmit(event)}
className="card stack"
Expand Down Expand Up @@ -230,7 +319,15 @@ export function Upload(): JSX.Element {

<div className="stack-sm">
<div className="row" style={{ justifyContent: 'space-between' }}>
<span className="ds-label">Upload progress</span>
<span className="ds-label">
{status === 'offline'
? 'Waiting for connection…'
: status === 'retrying'
? 'Retrying…'
: status === 'paused'
? 'Paused'
: 'Upload progress'}
</span>
<span className="ds-meta">{progress}%</span>
</div>
<div
Expand All @@ -244,15 +341,24 @@ export function Upload(): JSX.Element {
</div>
</div>

<div>
<button type="submit" className="btn" disabled={!isValidFile || !isEmailVerified}>
Upload
<div className="row" style={{ gap: '0.5rem' }}>
<button
type="submit"
className="btn"
disabled={!isValidFile || !isEmailVerified || status === 'uploading' || status === 'retrying'}
>
{canResumeWithFile ? 'Resume upload' : 'Upload'}
</button>
{status === 'uploading' || status === 'retrying' || status === 'offline' ? (
<button type="button" className="btn btn--secondary" onClick={() => void onCancel()}>
Cancel
</button>
) : null}
</div>
</form>

{error ? <p className="status-error">{error}</p> : null}
{status ? <p className="status-ok">{status}</p> : null}
{status === 'done' ? <p className="status-ok">Upload complete</p> : null}
</main>
);
}
Loading
Loading