diff --git a/src/modules/education/components/VideoUploadForm.tsx b/src/modules/education/components/VideoUploadForm.tsx index b515091..e3ebe86 100644 --- a/src/modules/education/components/VideoUploadForm.tsx +++ b/src/modules/education/components/VideoUploadForm.tsx @@ -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 = ({ 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 => { diff --git a/src/services/video-upload.service.ts b/src/services/video-upload.service.ts new file mode 100644 index 0000000..f1613fa --- /dev/null +++ b/src/services/video-upload.service.ts @@ -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 { + 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 { + 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