--- id: "ET-EDU-008" title: "Multipart Video Upload System" epic: "OQI-002" type: "Especificacion Tecnica" status: "implemented" priority: "P0" blocker: "BLOCKER-003" version: "1.0.0" created: "2026-01-26" updated: "2026-01-26" --- # ET-EDU-008: Multipart Video Upload System **Epic:** OQI-002 - Módulo Educativo **Blocker:** BLOCKER-003 (ST4.3) **Prioridad:** P0 - CRÍTICO **Estado:** ✅ Implemented (MVP - 89% complete) --- ## Resumen Ejecutivo Sistema completo de carga de videos educativos usando multipart upload a S3/R2 con backend Node.js y frontend React. Soporta archivos de hasta 2GB con upload directo al storage usando presigned URLs, procesamiento asíncrono (transcoding, thumbnails), y tracking completo del progreso. **Características Principales:** - ✅ Multipart upload (5MB parts) para videos grandes - ✅ Upload directo a S3/R2 (no pasa por backend) - ✅ Presigned URLs con expiración de 1 hora - ✅ Upload paralelo (max 3 parts simultáneos) - ✅ Progress tracking en tiempo real - ✅ Metadata completo (título, descripción, tags, dificultad) - ⚠️ Video processing (MVP - mock implementation) - 🔄 Future: FFmpeg/MediaConvert/Cloudflare Stream --- ## Arquitectura General ``` ┌──────────────────────────────────────────────────────────────────┐ │ VIDEO UPLOAD FLOW │ └──────────────────────────────────────────────────────────────────┘ ┌─────────────┐ ┌─────────┐ │ Browser │ │ S3/R2 │ │ (React) │ │ Storage │ └──────┬──────┘ └────┬────┘ │ │ │ 1. POST /videos/upload-init │ │ {courseId, metadata, fileSize} │ │ ──────────────────────────────────────┐ │ │ │ │ │ ┌────────────▼──────────┐ │ │ │ Backend (Express.js) │ │ │ │ video.controller.ts │ │ │ └────────────┬──────────┘ │ │ │ │ │ ┌────────────▼──────────┐ │ │ │ video.service.ts │ │ │ │ - Validate course │ │ │ │ - Generate key │ │ │ │ - Calculate parts │ │ │ └────────────┬──────────┘ │ │ │ │ │ ┌────────────▼──────────┐ │ │ │ storage.service.ts │ │ │ │ - Init multipart │ │ │ ◄────────────────────────│ - Create presigned │ │ │ {videoId, uploadId, │ URLs (1 per part) │ │ │ presignedUrls[]} └────────────┬──────────┘ │ │ │ │ │ ┌────────────▼──────────┐ │ │ │ PostgreSQL DB │ │ │ │ INSERT video record │ │ │ │ status='uploading' │ │ │ └───────────────────────┘ │ │ │ │ 2. Split file into 5MB parts │ │ Upload directly to S3/R2 │ │ ─────────────────────────────────────────────────────▶ │ │ │ ◄───────────────────────────────────────────────────── │ ETag for each part │ │ │ │ 3. POST /videos/:id/complete │ │ {parts: [{partNumber, etag}]} │ │ ──────────────────────────────────────┐ │ │ │ │ │ ┌────────────▼──────────┐ │ │ │ Backend │ │ │ │ - Verify ownership │ │ │ │ - Complete multipart │ │ │ │ - Update DB status │ │ │ │ - Trigger processing │──┐ │ └────────────┬──────────┘ │ │ │ │ │ ◄──────────────────────── │ │ │ {video, status='uploaded'} │ │ │ │ │ │ ▼ │ │ ┌────────────────────────┐ │ │ │ video-processing.service│ │ │ │ (Future: FFmpeg/Cloud) │ │ │ │ - Transcode resolutions│ │ │ │ - Generate thumbnail │ │ │ │ - Extract metadata │ │ │ └────────────────────────┘ │ └──────────────────────────────────────────────────────┘ ``` --- ## 1. Database Schema ### Table: `education.videos` ```sql CREATE TABLE education.videos ( -- Identity id UUID PRIMARY KEY DEFAULT gen_random_uuid(), course_id UUID NOT NULL REFERENCES education.courses(id), lesson_id UUID REFERENCES education.lessons(id), uploaded_by UUID NOT NULL REFERENCES core.users(id), -- Video Info title VARCHAR(200) NOT NULL, description TEXT, original_filename VARCHAR(255) NOT NULL, -- Storage storage_provider VARCHAR(50) DEFAULT 's3', -- 's3' or 'r2' storage_bucket VARCHAR(200) NOT NULL, storage_key VARCHAR(500) NOT NULL UNIQUE, storage_region VARCHAR(50), -- File Properties file_size_bytes BIGINT NOT NULL, mime_type VARCHAR(100) NOT NULL, duration_seconds INTEGER, -- Upload Status status VARCHAR(50) DEFAULT 'uploading', -- 'uploading', 'uploaded', 'processing', 'ready', 'error', 'deleted' upload_id VARCHAR(500), -- S3 multipart upload ID upload_parts_completed INTEGER DEFAULT 0, upload_parts_total INTEGER, upload_progress_percent INTEGER DEFAULT 0, -- Processing Status processing_started_at TIMESTAMPTZ, processing_completed_at TIMESTAMPTZ, processing_error TEXT, -- CDN & Variants cdn_url VARCHAR(1000), -- Primary video URL thumbnail_url VARCHAR(1000), -- Thumbnail image transcoded_versions JSONB, -- Array of {resolution, url, size} -- Metadata metadata JSONB NOT NULL DEFAULT '{}', -- {title, description, tags[], language, difficulty, transcript, captions[]} -- Timestamps created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), uploaded_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, -- Constraints CONSTRAINT videos_status_check CHECK (status IN ( 'uploading', 'uploaded', 'processing', 'ready', 'error', 'deleted' )), CONSTRAINT videos_progress_check CHECK ( upload_progress_percent >= 0 AND upload_progress_percent <= 100 ) ); -- Indexes CREATE INDEX idx_videos_course ON education.videos(course_id) WHERE deleted_at IS NULL; CREATE INDEX idx_videos_lesson ON education.videos(lesson_id) WHERE deleted_at IS NULL; CREATE INDEX idx_videos_status ON education.videos(status) WHERE deleted_at IS NULL; CREATE INDEX idx_videos_metadata ON education.videos USING GIN(metadata); -- Soft Delete Function CREATE OR REPLACE FUNCTION education.soft_delete_video(video_id UUID) RETURNS VOID AS $$ BEGIN UPDATE education.videos SET status = 'deleted', deleted_at = NOW(), updated_at = NOW() WHERE id = video_id; END; $$ LANGUAGE plpgsql; ``` **Metadata JSONB Structure:** ```typescript interface VideoMetadata { title: string; description: string; tags: string[]; // e.g., ["trading", "technical-analysis"] language: string; // e.g., "en", "es" difficulty: 'beginner' | 'intermediate' | 'advanced' | 'expert'; transcript?: string; // Full text transcript captions?: { // Multi-language captions language: string; url: string; // VTT/SRT file URL }[]; } ``` **Transcoded Versions JSONB Structure:** ```typescript interface TranscodedVersion { resolution: string; // "1080p", "720p", "480p" storageKey: string; // S3 key for transcoded file cdnUrl: string; // Public CDN URL fileSizeBytes: number; // File size in bytes width: number; // Video width (e.g., 1920) height: number; // Video height (e.g., 1080) } ``` --- ## 2. Backend Implementation ### 2.1 Storage Service **File:** `apps/backend/src/shared/services/storage.service.ts` ```typescript export class StorageService { private client: S3Client; constructor() { this.client = new S3Client({ region: process.env.STORAGE_REGION || 'us-east-1', endpoint: process.env.STORAGE_ENDPOINT, // For R2 credentials: { accessKeyId: process.env.STORAGE_ACCESS_KEY!, secretAccessKey: process.env.STORAGE_SECRET_KEY!, }, }); } // Initialize multipart upload async initMultipartUpload( key: string, contentType?: string, metadata?: Record ): Promise<{ uploadId: string; key: string }> { const command = new CreateMultipartUploadCommand({ Bucket: this.config.bucket, Key: key, ContentType: contentType, Metadata: metadata, }); const response = await this.client.send(command); return { uploadId: response.UploadId!, key }; } // Get presigned URL for part upload async getPresignedUploadUrl(options: { key: string; expiresIn: number; contentType?: string; }): Promise { const command = new PutObjectCommand({ Bucket: this.config.bucket, Key: options.key, ContentType: options.contentType, }); return await getSignedUrl(this.client, command, { expiresIn: options.expiresIn, }); } // Complete multipart upload async completeMultipartUpload( key: string, uploadId: string, parts: CompletedPart[] ): Promise<{ key: string; url: string }> { const command = new CompleteMultipartUploadCommand({ Bucket: this.config.bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts }, }); await this.client.send(command); return { key, url: this.getPublicUrl(key) }; } // Abort multipart upload (cleanup) async abortMultipartUpload(key: string, uploadId: string): Promise { const command = new AbortMultipartUploadCommand({ Bucket: this.config.bucket, Key: key, UploadId: uploadId, }); await this.client.send(command); } } ``` **Key Concepts:** - **Multipart Upload:** Split large files into parts (5MB each) - **Presigned URLs:** Client uploads directly to S3/R2 without backend proxy - **Upload ID:** Unique identifier for multipart upload session - **ETags:** S3 returns ETag for each part (required for completion) ### 2.2 Video Service **File:** `apps/backend/src/modules/education/services/video.service.ts` ```typescript export class VideoService { async initializeUpload( userId: string, data: InitUploadRequest ): Promise { // 1. Validate course access await this.validateCourseAccess(data.courseId, userId); // 2. Generate storage key const storageKey = storageService.generateKey('videos', data.filename); // 3. Initialize multipart upload const { uploadId } = await storageService.initMultipartUpload( storageKey, data.contentType, { title: data.metadata.title, courseId: data.courseId, userId } ); // 4. Calculate number of parts (5MB each) const PART_SIZE = 5 * 1024 * 1024; const totalParts = Math.ceil(data.fileSize / PART_SIZE); // 5. Create video record in database const video = await db.query