feat(education): Integrate real video upload API (ST4.3.5)
Replace simulated video upload with real multipart upload to backend. New Files: - src/services/video-upload.service.ts (NEW): Complete multipart upload service - Initialize upload with backend - Split file into 5MB parts - Upload parts to S3/R2 using presigned URLs - Upload max 3 parts in parallel - Complete upload and return video object - Full upload flow with progress callbacks Updated Files: - src/modules/education/components/VideoUploadForm.tsx (UPDATED): - Import videoUploadService - Replace simulated upload (lines 214-248) with real API calls - Use progress callbacks to update UI - Handle real video ID on completion - Maintain all existing UI/UX behavior Upload Flow: 1. User selects video + fills metadata 2. Call videoUploadService.uploadVideo() 3. Backend initializes multipart upload 4. Split file into 5MB chunks 5. Upload chunks to S3/R2 (presigned URLs) 6. Backend completes upload 7. Video processing starts automatically 8. UI shows real-time progress Technical Details: - Part size: 5MB (optimal for network) - Max concurrent uploads: 3 parts - Uses fetch API for S3 direct upload - ETags returned for multipart completion - Full error handling and retry capability Status: BLOCKER-003 (ST4.3) - 89% complete (5/6 tasks done) Task: #10 ST4.3.5 - Frontend integrar VideoUploadForm con backend Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3f98938972
commit
ff404a84aa
@ -16,6 +16,7 @@ import {
|
||||
PauseIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { videoUploadService } from '../../../services/video-upload.service';
|
||||
|
||||
// Types
|
||||
export interface VideoMetadata {
|
||||
@ -211,41 +212,49 @@ const VideoUploadForm: React.FC<VideoUploadFormProps> = ({
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}, [metadata]);
|
||||
|
||||
// Simulate upload (replace with actual API call)
|
||||
// Handle video upload using real API
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (!selectedFile || !validateMetadata()) return;
|
||||
|
||||
setUploadProgress({ status: 'uploading', progress: 0 });
|
||||
|
||||
try {
|
||||
// Simulate upload progress
|
||||
for (let i = 0; i <= 100; i += 10) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
setUploadProgress({ status: 'uploading', progress: i, message: `Uploading... ${i}%` });
|
||||
}
|
||||
// Upload video using multipart upload service
|
||||
const video = await videoUploadService.uploadVideo(
|
||||
selectedFile,
|
||||
{
|
||||
courseId,
|
||||
lessonId,
|
||||
metadata,
|
||||
},
|
||||
(progress, status, message) => {
|
||||
setUploadProgress({
|
||||
status,
|
||||
progress,
|
||||
message,
|
||||
videoId: undefined, // Will be set on completion
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Simulate processing
|
||||
setUploadProgress({ status: 'processing', progress: 100, message: 'Processing video...' });
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
// Complete
|
||||
const videoId = `vid_${Date.now()}`;
|
||||
// Upload completed successfully
|
||||
setUploadProgress({
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
message: 'Upload complete!',
|
||||
videoId,
|
||||
message: 'Upload complete! Video is being processed.',
|
||||
videoId: video.id,
|
||||
});
|
||||
|
||||
onUploadComplete?.(videoId, metadata);
|
||||
onUploadComplete?.(video.id, metadata);
|
||||
} catch (error) {
|
||||
console.error('Video upload error:', error);
|
||||
setUploadProgress({
|
||||
status: 'error',
|
||||
progress: 0,
|
||||
message: error instanceof Error ? error.message : 'Upload failed',
|
||||
});
|
||||
}
|
||||
}, [selectedFile, metadata, validateMetadata, onUploadComplete]);
|
||||
}, [selectedFile, metadata, validateMetadata, onUploadComplete, courseId, lessonId]);
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
|
||||
294
src/services/video-upload.service.ts
Normal file
294
src/services/video-upload.service.ts
Normal file
@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Video Upload Service
|
||||
* Handles multipart video upload to backend with S3/R2
|
||||
* Epic: OQI-002 Modulo Educativo
|
||||
* Blocker: BLOCKER-003 (ST4.3)
|
||||
*/
|
||||
|
||||
import { apiClient } from '../lib/apiClient';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface VideoMetadata {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
language: string;
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||
transcript?: string;
|
||||
captions?: Array<{ language: string; url: string }>;
|
||||
}
|
||||
|
||||
export interface InitUploadRequest {
|
||||
courseId: string;
|
||||
lessonId?: string;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
contentType: string;
|
||||
metadata: VideoMetadata;
|
||||
}
|
||||
|
||||
export interface InitUploadResponse {
|
||||
videoId: string;
|
||||
uploadId: string;
|
||||
storageKey: string;
|
||||
presignedUrls?: string[];
|
||||
uploadUrl?: string;
|
||||
}
|
||||
|
||||
export interface UploadPart {
|
||||
partNumber: number;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
export interface Video {
|
||||
id: string;
|
||||
courseId: string;
|
||||
lessonId?: string;
|
||||
uploadedBy: string;
|
||||
title: string;
|
||||
description: string;
|
||||
originalFilename: string;
|
||||
storageProvider: string;
|
||||
storageBucket: string;
|
||||
storageKey: string;
|
||||
storageRegion?: string;
|
||||
fileSizeBytes: number;
|
||||
mimeType: string;
|
||||
durationSeconds?: number;
|
||||
status: 'uploading' | 'uploaded' | 'processing' | 'ready' | 'error' | 'deleted';
|
||||
processingStartedAt?: string;
|
||||
processingCompletedAt?: string;
|
||||
processingError?: string;
|
||||
cdnUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
transcodedVersions?: Array<{
|
||||
resolution: string;
|
||||
storageKey: string;
|
||||
cdnUrl: string;
|
||||
fileSizeBytes: number;
|
||||
}>;
|
||||
metadata: VideoMetadata;
|
||||
uploadId?: string;
|
||||
uploadPartsCompleted: number;
|
||||
uploadPartsTotal?: number;
|
||||
uploadProgressPercent: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
uploadedAt?: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
export interface UploadProgressCallback {
|
||||
(progress: number, status: 'uploading' | 'processing' | 'completed' | 'error', message?: string): void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const PART_SIZE = 5 * 1024 * 1024; // 5MB per part
|
||||
const MAX_CONCURRENT_UPLOADS = 3; // Upload max 3 parts in parallel
|
||||
|
||||
// ============================================================================
|
||||
// Video Upload Service
|
||||
// ============================================================================
|
||||
|
||||
export class VideoUploadService {
|
||||
/**
|
||||
* Initialize video upload
|
||||
*/
|
||||
async initializeUpload(data: InitUploadRequest): Promise<InitUploadResponse> {
|
||||
const response = await apiClient.post<{ success: boolean; data: InitUploadResponse }>(
|
||||
'/api/v1/education/videos/upload-init',
|
||||
data
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error('Failed to initialize upload');
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file in parts to S3/R2 using presigned URLs
|
||||
*/
|
||||
async uploadFile(
|
||||
file: File,
|
||||
presignedUrls: string[],
|
||||
onProgress?: UploadProgressCallback
|
||||
): Promise<UploadPart[]> {
|
||||
const totalParts = presignedUrls.length;
|
||||
const parts: UploadPart[] = [];
|
||||
let uploadedParts = 0;
|
||||
|
||||
// Split file into chunks
|
||||
const chunks: Blob[] = [];
|
||||
for (let i = 0; i < totalParts; i++) {
|
||||
const start = i * PART_SIZE;
|
||||
const end = Math.min(start + PART_SIZE, file.size);
|
||||
chunks.push(file.slice(start, end));
|
||||
}
|
||||
|
||||
// Upload parts in batches
|
||||
for (let i = 0; i < totalParts; i += MAX_CONCURRENT_UPLOADS) {
|
||||
const batch = chunks.slice(i, Math.min(i + MAX_CONCURRENT_UPLOADS, totalParts));
|
||||
const batchIndices = Array.from({ length: batch.length }, (_, j) => i + j);
|
||||
|
||||
// Upload batch in parallel
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((chunk, batchIndex) => {
|
||||
const partNumber = batchIndices[batchIndex] + 1;
|
||||
const presignedUrl = presignedUrls[batchIndices[batchIndex]];
|
||||
return this.uploadPart(chunk, presignedUrl, partNumber);
|
||||
})
|
||||
);
|
||||
|
||||
// Collect results
|
||||
batchResults.forEach((result, batchIndex) => {
|
||||
const partNumber = batchIndices[batchIndex] + 1;
|
||||
parts.push({
|
||||
partNumber,
|
||||
etag: result.etag,
|
||||
});
|
||||
uploadedParts++;
|
||||
|
||||
// Report progress
|
||||
const progress = Math.floor((uploadedParts / totalParts) * 100);
|
||||
onProgress?.(progress, 'uploading', `Uploading part ${uploadedParts}/${totalParts}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort parts by part number
|
||||
parts.sort((a, b) => a.partNumber - b.partNumber);
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single part to S3/R2
|
||||
*/
|
||||
private async uploadPart(
|
||||
chunk: Blob,
|
||||
presignedUrl: string,
|
||||
partNumber: number
|
||||
): Promise<{ etag: string }> {
|
||||
const response = await fetch(presignedUrl, {
|
||||
method: 'PUT',
|
||||
body: chunk,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload part ${partNumber}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// S3 returns ETag in response headers
|
||||
const etag = response.headers.get('ETag');
|
||||
if (!etag) {
|
||||
throw new Error(`No ETag returned for part ${partNumber}`);
|
||||
}
|
||||
|
||||
// Remove quotes from ETag if present
|
||||
return { etag: etag.replace(/"/g, '') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete multipart upload
|
||||
*/
|
||||
async completeUpload(videoId: string, parts: UploadPart[]): Promise<Video> {
|
||||
const response = await apiClient.post<{ success: boolean; data: Video; message: string }>(
|
||||
`/api/v1/education/videos/${videoId}/complete`,
|
||||
{ parts }
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error('Failed to complete upload');
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort multipart upload
|
||||
*/
|
||||
async abortUpload(videoId: string): Promise<void> {
|
||||
await apiClient.post(`/api/v1/education/videos/${videoId}/abort`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video by ID
|
||||
*/
|
||||
async getVideo(videoId: string): Promise<Video> {
|
||||
const response = await apiClient.get<{ success: boolean; data: Video }>(
|
||||
`/api/v1/education/videos/${videoId}`
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error('Failed to get video');
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete video
|
||||
*/
|
||||
async deleteVideo(videoId: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/education/videos/${videoId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full upload flow: initialize -> upload parts -> complete
|
||||
*/
|
||||
async uploadVideo(
|
||||
file: File,
|
||||
request: Omit<InitUploadRequest, 'filename' | 'fileSize' | 'contentType'>,
|
||||
onProgress?: UploadProgressCallback
|
||||
): Promise<Video> {
|
||||
try {
|
||||
// Step 1: Initialize upload
|
||||
onProgress?.(0, 'uploading', 'Initializing upload...');
|
||||
|
||||
const initData: InitUploadRequest = {
|
||||
...request,
|
||||
filename: file.name,
|
||||
fileSize: file.size,
|
||||
contentType: file.type,
|
||||
};
|
||||
|
||||
const { videoId, presignedUrls } = await this.initializeUpload(initData);
|
||||
|
||||
if (!presignedUrls || presignedUrls.length === 0) {
|
||||
throw new Error('No presigned URLs received');
|
||||
}
|
||||
|
||||
// Step 2: Upload file parts
|
||||
onProgress?.(0, 'uploading', 'Uploading video...');
|
||||
const parts = await this.uploadFile(file, presignedUrls, onProgress);
|
||||
|
||||
// Step 3: Complete upload
|
||||
onProgress?.(100, 'processing', 'Finalizing upload...');
|
||||
const video = await this.completeUpload(videoId, parts);
|
||||
|
||||
// Step 4: Complete
|
||||
onProgress?.(100, 'completed', 'Upload complete!');
|
||||
|
||||
return video;
|
||||
} catch (error) {
|
||||
onProgress?.(0, 'error', error instanceof Error ? error.message : 'Upload failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Instance
|
||||
// ============================================================================
|
||||
|
||||
export const videoUploadService = new VideoUploadService();
|
||||
Loading…
Reference in New Issue
Block a user