Back to blog

Xây Dựng Video Platform: Video Transcoding Pipeline

javaspring-bootreactnextjsvideo-streaming
Xây Dựng Video Platform: Video Transcoding Pipeline

Video đã lên disk, nhưng chúng vẫn là raw MP4 file. Một video 1080p, 30 phút nặng khoảng 2GB — không thể serve thẳng cho user đang dùng 4G được. Trong bài này, chúng ta sẽ build transcoding pipeline chuyển đổi mỗi upload thành adaptive HLS stream ở nhiều resolution.

HLS (HTTP Live Streaming) chia video thành segment nhỏ (thường 10 giây mỗi segment) và tạo playlist cho phép player chuyển đổi quality level linh hoạt. Mạng chậm đang buffer? Chuyển sang 360p. WiFi nhanh? Nhảy lên 720p. Đó là adaptive bitrate streaming.

Thời gian: 3–4 giờ
Yêu cầu: Phase 5: Video Upload & Storage

Những gì chúng ta sẽ build trong bài này:
✅ FFmpeg HLS transcode sang 360p và 720p
✅ Async processing với Spring @Async và thread pool
✅ Progress tracking trong Redis với percentage update
✅ ffprobe metadata extraction (duration, resolution, codec)
✅ Tự động trigger transcode sau upload
✅ Admin UI cho transcoding status và progress
✅ Master playlist generation cho adaptive streaming


HLS Hoạt Động Thế Nào

Trước khi viết code, hãy hiểu chúng ta đang build gì:

Output cho mỗi lesson trông như thế này:

hls/
└── {courseId}/
    └── {lessonId}/
        ├── master.m3u8          # Trỏ tới playlist từng quality
        ├── 360p/
        │   ├── playlist.m3u8   # Liệt kê tất cả segment 360p
        │   ├── segment-000.ts  # Chunk video 10 giây
        │   ├── segment-001.ts
        │   └── ...
        └── 720p/
            ├── playlist.m3u8
            ├── segment-000.ts
            ├── segment-001.ts
            └── ...

Master playlist (master.m3u8) cho player biết có những quality level nào. Player chọn một cái dựa trên tình trạng mạng rồi bắt đầu download segment.


Setup FFmpeg

Thêm FFmpeg vào Docker

# api/Dockerfile
FROM eclipse-temurin:21-jre-alpine
 
# Cài FFmpeg
RUN apk add --no-cache ffmpeg
 
WORKDIR /app
COPY build/libs/*.jar app.jar
 
ENTRYPOINT ["java", "-jar", "app.jar"]

Cho local development, cài FFmpeg:

# macOS
brew install ffmpeg
 
# Ubuntu/Debian
sudo apt install ffmpeg
 
# Kiểm tra cài đặt
ffmpeg -version
ffprobe -version

Trích Xuất Video Metadata với ffprobe

Trước khi transcode, chúng ta extract metadata từ video đã upload — duration, resolution, codec, và bitrate. Thông tin này giúp đưa ra quyết định transcode và hiển thị lesson duration cho user.

// src/main/java/com/videoplatform/api/service/VideoMetadataService.java
package com.videoplatform.api.service;
 
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.file.Path;
import java.util.stream.Collectors;
 
@Service
public class VideoMetadataService {
 
    private static final Logger log = LoggerFactory.getLogger(VideoMetadataService.class);
    private final ObjectMapper objectMapper = new ObjectMapper();
 
    public VideoMetadata extractMetadata(Path videoPath) {
        try {
            ProcessBuilder pb = new ProcessBuilder(
                "ffprobe",
                "-v", "quiet",
                "-print_format", "json",
                "-show_format",
                "-show_streams",
                videoPath.toString()
            );
            pb.redirectErrorStream(true);
 
            Process process = pb.start();
            String output;
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(process.getInputStream()))) {
                output = reader.lines().collect(Collectors.joining("\n"));
            }
 
            int exitCode = process.waitFor();
            if (exitCode != 0) {
                throw new RuntimeException("ffprobe failed with exit code: " + exitCode);
            }
 
            return parseMetadata(output);
 
        } catch (Exception e) {
            log.error("Failed to extract metadata from {}", videoPath, e);
            return VideoMetadata.unknown();
        }
    }
 
    private VideoMetadata parseMetadata(String json) throws Exception {
        JsonNode root = objectMapper.readTree(json);
 
        // Lấy duration từ format
        double duration = root.path("format").path("duration").asDouble(0);
 
        // Tìm video stream
        JsonNode streams = root.path("streams");
        int width = 0, height = 0;
        String codec = "unknown";
 
        for (JsonNode stream : streams) {
            if ("video".equals(stream.path("codec_type").asText())) {
                width = stream.path("width").asInt(0);
                height = stream.path("height").asInt(0);
                codec = stream.path("codec_name").asText("unknown");
                break;
            }
        }
 
        return new VideoMetadata(
            (int) Math.round(duration),
            width,
            height,
            codec
        );
    }
 
    public record VideoMetadata(
        int durationSeconds,
        int width,
        int height,
        String codec
    ) {
        public static VideoMetadata unknown() {
            return new VideoMetadata(0, 0, 0, "unknown");
        }
 
        public String resolution() {
            return width + "x" + height;
        }
    }
}

Ví dụ ffprobe output (parse nội bộ):

{
  "format": {
    "duration": "1847.52",
    "size": "1572864000",
    "bit_rate": "6808000"
  },
  "streams": [
    {
      "codec_type": "video",
      "codec_name": "h264",
      "width": 1920,
      "height": 1080
    },
    {
      "codec_type": "audio",
      "codec_name": "aac"
    }
  ]
}

Transcoding Service

Đây là core của pipeline. Nó chạy FFmpeg để convert raw video thành HLS segment ở nhiều resolution.

// src/main/java/com/videoplatform/api/service/TranscodingService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.config.StorageConfig;
import com.videoplatform.api.model.enums.LessonStatus;
import com.videoplatform.api.service.VideoMetadataService.VideoMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
 
@Service
public class TranscodingService {
 
    private static final Logger log = LoggerFactory.getLogger(TranscodingService.class);
 
    private final StorageConfig storageConfig;
    private final VideoMetadataService metadataService;
    private final TranscodingProgressService progressService;
    private final LessonService lessonService;
 
    public TranscodingService(StorageConfig storageConfig,
                               VideoMetadataService metadataService,
                               TranscodingProgressService progressService,
                               LessonService lessonService) {
        this.storageConfig = storageConfig;
        this.metadataService = metadataService;
        this.progressService = progressService;
        this.lessonService = lessonService;
    }
 
    @Async("transcodingExecutor")
    public void transcodeVideo(Long courseId, Long lessonId, String rawVideoPath) {
        Path inputPath = Path.of(storageConfig.getBasePath(), rawVideoPath);
        Path outputDir = Path.of(
            storageConfig.getHlsPath(),
            String.valueOf(courseId),
            String.valueOf(lessonId)
        );
 
        try {
            log.info("Starting transcoding: course={} lesson={}", courseId, lessonId);
            progressService.updateProgress(lessonId, "TRANSCODING", 0);
 
            // 1. Extract metadata
            VideoMetadata metadata = metadataService.extractMetadata(inputPath);
            log.info("Video metadata: {}x{}, {}s, codec={}",
                     metadata.width(), metadata.height(),
                     metadata.durationSeconds(), metadata.codec());
 
            // Update lesson duration
            lessonService.updateDuration(lessonId, metadata.durationSeconds());
 
            // 2. Xác định output resolution
            List<TranscodeProfile> profiles = getProfiles(metadata.height());
 
            // 3. Tạo output directory
            Files.createDirectories(outputDir);
 
            // 4. Transcode từng resolution
            for (int i = 0; i < profiles.size(); i++) {
                TranscodeProfile profile = profiles.get(i);
                int baseProgress = (i * 90) / profiles.size();
                int nextProgress = ((i + 1) * 90) / profiles.size();
 
                transcodeToHls(inputPath, outputDir, profile, lessonId,
                              metadata.durationSeconds(), baseProgress, nextProgress);
            }
 
            // 5. Generate master playlist
            generateMasterPlaylist(outputDir, profiles);
 
            // 6. Update lesson status
            String hlsPath = String.format("hls/%d/%d/master.m3u8", courseId, lessonId);
            lessonService.updateVideoStatus(lessonId, LessonStatus.READY, hlsPath);
            progressService.updateProgress(lessonId, "COMPLETE", 100);
 
            log.info("Transcoding complete: course={} lesson={}", courseId, lessonId);
 
        } catch (Exception e) {
            log.error("Transcoding failed: course={} lesson={}", courseId, lessonId, e);
            lessonService.updateVideoStatus(lessonId, LessonStatus.FAILED, null);
            progressService.updateProgress(lessonId, "FAILED", -1);
        }
    }
 
    private List<TranscodeProfile> getProfiles(int sourceHeight) {
        List<TranscodeProfile> profiles = new ArrayList<>();
 
        // Luôn có 360p
        profiles.add(new TranscodeProfile("360p", 640, 360, "800k", "96k"));
 
        // Thêm 720p nếu source ít nhất 720p
        if (sourceHeight >= 720) {
            profiles.add(new TranscodeProfile("720p", 1280, 720, "2500k", "128k"));
        }
 
        return profiles;
    }
 
    private void transcodeToHls(Path input, Path outputDir, TranscodeProfile profile,
                                 Long lessonId, int totalDuration,
                                 int baseProgress, int nextProgress) throws Exception {
 
        Path profileDir = outputDir.resolve(profile.name());
        Files.createDirectories(profileDir);
 
        Path playlistPath = profileDir.resolve("playlist.m3u8");
        Path segmentPattern = profileDir.resolve("segment-%03d.ts");
 
        List<String> command = List.of(
            "ffmpeg",
            "-i", input.toString(),
            "-vf", String.format("scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2",
                                  profile.width(), profile.height(),
                                  profile.width(), profile.height()),
            "-c:v", "libx264",
            "-preset", "medium",
            "-b:v", profile.videoBitrate(),
            "-maxrate", profile.videoBitrate(),
            "-bufsize", String.valueOf(parseBitrate(profile.videoBitrate()) * 2),
            "-c:a", "aac",
            "-b:a", profile.audioBitrate(),
            "-ac", "2",
            "-ar", "44100",
            "-hls_time", "10",
            "-hls_list_size", "0",
            "-hls_segment_filename", segmentPattern.toString(),
            "-hls_playlist_type", "vod",
            "-f", "hls",
            "-y",
            "-progress", "pipe:1",
            playlistPath.toString()
        );
 
        log.info("Transcoding to {}: {}", profile.name(), String.join(" ", command));
 
        ProcessBuilder pb = new ProcessBuilder(command);
        pb.redirectErrorStream(true);
        Process process = pb.start();
 
        // Parse FFmpeg progress output
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(process.getInputStream()))) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.startsWith("out_time_us=")) {
                    long microseconds = Long.parseLong(line.split("=")[1]);
                    int currentSeconds = (int) (microseconds / 1_000_000);
 
                    if (totalDuration > 0) {
                        double ratio = (double) currentSeconds / totalDuration;
                        int progress = baseProgress +
                                       (int) (ratio * (nextProgress - baseProgress));
                        progress = Math.min(progress, nextProgress);
                        progressService.updateProgress(lessonId, "TRANSCODING", progress);
                    }
                }
            }
        }
 
        int exitCode = process.waitFor();
        if (exitCode != 0) {
            throw new RuntimeException("FFmpeg failed for " + profile.name() +
                                      " with exit code: " + exitCode);
        }
 
        log.info("Transcoding complete for {}: {}", profile.name(), playlistPath);
    }
 
    private void generateMasterPlaylist(Path outputDir, List<TranscodeProfile> profiles) throws Exception {
        StringBuilder playlist = new StringBuilder();
        playlist.append("#EXTM3U\n");
        playlist.append("#EXT-X-VERSION:3\n\n");
 
        for (TranscodeProfile profile : profiles) {
            int bandwidth = parseBitrate(profile.videoBitrate()) +
                           parseBitrate(profile.audioBitrate());
            playlist.append(String.format(
                "#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d,NAME=\"%s\"\n",
                bandwidth, profile.width(), profile.height(), profile.name()
            ));
            playlist.append(profile.name()).append("/playlist.m3u8\n\n");
        }
 
        Files.writeString(outputDir.resolve("master.m3u8"), playlist.toString());
    }
 
    private int parseBitrate(String bitrate) {
        String value = bitrate.replaceAll("[^0-9]", "");
        int num = Integer.parseInt(value);
        if (bitrate.endsWith("k")) return num * 1000;
        if (bitrate.endsWith("m")) return num * 1_000_000;
        return num;
    }
 
    record TranscodeProfile(
        String name,
        int width,
        int height,
        String videoBitrate,
        String audioBitrate
    ) {}
}

Giải Thích FFmpeg Command

Hãy phân tích FFmpeg command — nó làm rất nhiều thứ:

FlagMục đích
-i input.mp4File đầu vào
-vf scale=...Resize video, giữ aspect ratio, thêm padding nếu cần
-c:v libx264Encode video bằng H.264 (hỗ trợ mọi browser)
-preset mediumCân bằng giữa tốc độ encode và kích thước file
-b:v 2500kTarget video bitrate (720p)
-maxrate / -bufsizeGiới hạn bitrate cho streaming ổn định
-c:a aac -b:a 128kAAC audio ở 128kbps
-hls_time 10Mỗi segment dài 10 giây
-hls_list_size 0Đưa tất cả segment vào playlist (VOD)
-hls_playlist_type vodBáo hiệu đây là video-on-demand, không phải live
-progress pipe:1Xuất progress ra stdout để tracking
-yGhi đè output file mà không hỏi

Logic Chọn Resolution

Chúng ta chỉ transcode sang resolution thấp hơn hoặc bằng source:

Upscale video 480p lên 720p chỉ tốn bandwidth mà không cải thiện chất lượng — nên chúng ta bỏ qua.


Async Processing với @Async

Transcoding mất vài phút (hoặc lâu hơn với file lớn). Không thể block upload request đợi nó xong. Spring @Async chạy transcoding trong background thread.

Cấu Hình Thread Pool

// src/main/java/com/videoplatform/api/config/AsyncConfig.java
package com.videoplatform.api.config;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
 
@Configuration
@EnableAsync
public class AsyncConfig {
 
    @Bean(name = "transcodingExecutor")
    public Executor transcodingExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);        // 2 job transcode đồng thời
        executor.setMaxPoolSize(4);          // Burst lên tối đa 4
        executor.setQueueCapacity(50);       // Queue tối đa 50 job chờ
        executor.setThreadNamePrefix("transcode-");
        executor.initialize();
        return executor;
    }
}

Tại sao giới hạn 2–4 job đồng thời? FFmpeg rất tốn CPU. Trên VPS 4 core điển hình, chạy hơn 2 transcoding job cùng lúc làm tất cả đều chậm hơn. Tốt hơn là queue rồi xử lý tuần tự.


Progress Tracking trong Redis

Redis lưu transcoding progress real-time để mọi client (bao gồm admin dashboard) có thể poll status:

// src/main/java/com/videoplatform/api/service/TranscodingProgressService.java
package com.videoplatform.api.service;
 
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
 
@Service
public class TranscodingProgressService {
 
    private static final String KEY_PREFIX = "transcode:progress:";
    private static final Duration TTL = Duration.ofHours(24);
 
    private final StringRedisTemplate redisTemplate;
 
    public TranscodingProgressService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
 
    public void updateProgress(Long lessonId, String status, int percentage) {
        String key = KEY_PREFIX + lessonId;
        Map<String, String> data = new HashMap<>();
        data.put("status", status);
        data.put("percentage", String.valueOf(percentage));
        data.put("updatedAt", String.valueOf(System.currentTimeMillis()));
 
        redisTemplate.opsForHash().putAll(key, data);
        redisTemplate.expire(key, TTL);
    }
 
    public TranscodeProgress getProgress(Long lessonId) {
        String key = KEY_PREFIX + lessonId;
        Map<Object, Object> data = redisTemplate.opsForHash().entries(key);
 
        if (data.isEmpty()) {
            return new TranscodeProgress("UNKNOWN", 0);
        }
 
        return new TranscodeProgress(
            (String) data.getOrDefault("status", "UNKNOWN"),
            Integer.parseInt((String) data.getOrDefault("percentage", "0"))
        );
    }
 
    public void clearProgress(Long lessonId) {
        redisTemplate.delete(KEY_PREFIX + lessonId);
    }
 
    public record TranscodeProgress(String status, int percentage) {}
}

Tại sao Redis thay vì database column? Vì progress update xảy ra mỗi vài giây trong lúc transcode. Ghi vào PostgreSQL thường xuyên như vậy rất lãng phí. Redis xử lý dễ dàng, và data này là ephemeral — không cần nó nữa khi transcode xong.


Trigger Transcode Sau Upload

Update upload controller để tự động kick off transcoding:

// Update AdminUploadController.java — sửa method uploadVideo
 
@PostMapping("/video")
public ResponseEntity<ApiResponse<UploadResponse>> uploadVideo(
        @RequestParam("file") MultipartFile file,
        @RequestParam("courseId") Long courseId,
        @RequestParam("lessonId") Long lessonId) {
 
    // 1. Validate file
    validationService.validateVideoFile(file);
 
    // 2. Lưu lên disk
    String videoPath = storageService.storeRawVideo(file, courseId, lessonId);
 
    // 3. Update lesson record
    lessonService.updateVideoPath(lessonId, videoPath);
 
    // 4. Trigger async transcoding
    transcodingService.transcodeVideo(courseId, lessonId, videoPath);
 
    // 5. Trả response (transcoding chạy ở background)
    UploadResponse response = new UploadResponse(
        videoPath,
        file.getSize(),
        file.getOriginalFilename(),
        file.getContentType()
    );
 
    return ResponseEntity.ok(
        ApiResponse.success("Video uploaded. Transcoding started.", response)
    );
}

Response trả về ngay sau khi file được lưu. Transcoding chạy ở background — admin có thể kiểm tra progress qua status endpoint.

Thêm LessonService Method

// Thêm vào LessonService.java
 
@Transactional
public void updateDuration(Long lessonId, int durationSeconds) {
    Lesson lesson = lessonRepository.findById(lessonId)
            .orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
    lesson.setDuration(durationSeconds);
    lessonRepository.save(lesson);
 
    // Update course total duration
    courseService.refreshCourseStats(lesson.getSection().getCourse().getId());
}
 
@Transactional
public void updateVideoStatus(Long lessonId, LessonStatus status, String hlsPath) {
    Lesson lesson = lessonRepository.findById(lessonId)
            .orElseThrow(() -> new ResourceNotFoundException("Lesson not found: " + lessonId));
    lesson.setStatus(status);
    if (hlsPath != null) {
        lesson.setVideoPath(hlsPath);
    }
    lessonRepository.save(lesson);
}

Transcoding Status API

Thêm endpoint kiểm tra transcoding progress:

// Thêm vào AdminUploadController.java
 
@GetMapping("/transcode-status/{lessonId}")
public ResponseEntity<ApiResponse<TranscodeStatusResponse>> getTranscodeStatus(
        @PathVariable Long lessonId) {
 
    var progress = progressService.getProgress(lessonId);
 
    return ResponseEntity.ok(ApiResponse.success(
        new TranscodeStatusResponse(
            progress.status(),
            progress.percentage()
        )
    ));
}
// src/main/java/com/videoplatform/api/dto/response/TranscodeStatusResponse.java
package com.videoplatform.api.dto.response;
 
public record TranscodeStatusResponse(
    String status,
    int percentage
) {}

Admin UI: Transcoding Progress

Update lesson row trong admin dashboard để hiện transcoding status:

// web/src/components/admin/TranscodeStatus.tsx
"use client";
 
import { useEffect, useState } from "react";
import { adminApi } from "@/lib/admin-api";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Loader2, CheckCircle2, XCircle, Clock } from "lucide-react";
 
interface TranscodeStatusProps {
  lessonId: number;
  lessonStatus: string;
}
 
export function TranscodeStatus({ lessonId, lessonStatus }: TranscodeStatusProps) {
  const [progress, setProgress] = useState<{ status: string; percentage: number } | null>(null);
  const [polling, setPolling] = useState(lessonStatus === "PROCESSING");
 
  useEffect(() => {
    if (!polling) return;
 
    const interval = setInterval(async () => {
      try {
        const data = await adminApi.getTranscodeStatus(lessonId);
        setProgress(data);
 
        if (data.status === "COMPLETE" || data.status === "FAILED") {
          setPolling(false);
        }
      } catch {
        setPolling(false);
      }
    }, 2000); // Poll mỗi 2 giây
 
    return () => clearInterval(interval);
  }, [lessonId, polling]);
 
  if (lessonStatus === "READY") {
    return (
      <Badge variant="outline" className="text-green-600">
        <CheckCircle2 className="h-3 w-3 mr-1" />
        Ready
      </Badge>
    );
  }
 
  if (lessonStatus === "FAILED") {
    return (
      <Badge variant="outline" className="text-red-600">
        <XCircle className="h-3 w-3 mr-1" />
        Failed
      </Badge>
    );
  }
 
  if (lessonStatus === "PROCESSING" && progress) {
    return (
      <div className="flex items-center gap-2 min-w-[150px]">
        <Loader2 className="h-3 w-3 animate-spin text-primary" />
        <Progress value={progress.percentage} className="h-1.5 flex-1" />
        <span className="text-xs text-muted-foreground">{progress.percentage}%</span>
      </div>
    );
  }
 
  if (lessonStatus === "PROCESSING") {
    return (
      <Badge variant="outline" className="text-yellow-600">
        <Clock className="h-3 w-3 mr-1" />
        Queued
      </Badge>
    );
  }
 
  return null;
}
// Thêm vào admin-api.ts
getTranscodeStatus: (lessonId: number) =>
  api
    .get<{ data: { status: string; percentage: number } }>(
      `/api/admin/upload/transcode-status/${lessonId}`
    )
    .then((r) => r.data.data),

Complete Transcoding Pipeline

Đây là full flow từ upload đến HLS có thể phát:


Ví Dụ Master Playlist

Đây là master.m3u8 được sinh ra:

#EXTM3U
#EXT-X-VERSION:3
 
#EXT-X-STREAM-INF:BANDWIDTH=896000,RESOLUTION=640x360,NAME="360p"
360p/playlist.m3u8
 
#EXT-X-STREAM-INF:BANDWIDTH=2628000,RESOLUTION=1280x720,NAME="720p"
720p/playlist.m3u8

Và playlist.m3u8 cho từng quality:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
 
#EXTINF:10.000000,
segment-000.ts
#EXTINF:10.000000,
segment-001.ts
#EXTINF:10.000000,
segment-002.ts
#EXTINF:7.520000,
segment-003.ts
 
#EXT-X-ENDLIST

Test Pipeline

1. Upload và Transcode

# Upload video — transcoding bắt đầu tự động
curl -X POST http://localhost:8080/api/admin/upload/video \
  -H "Authorization: Bearer ${TOKEN}" \
  -F "[email protected]" \
  -F "courseId=1" \
  -F "lessonId=1"

2. Kiểm Tra Progress

# Poll transcoding status
watch -n 2 'curl -s http://localhost:8080/api/admin/upload/transcode-status/1 \
  -H "Authorization: Bearer ${TOKEN}" | jq'

Bạn sẽ thấy progress tăng dần:

{ "status": "TRANSCODING", "percentage": 23 }
{ "status": "TRANSCODING", "percentage": 47 }
{ "status": "TRANSCODING", "percentage": 71 }
{ "status": "TRANSCODING", "percentage": 95 }
{ "status": "COMPLETE", "percentage": 100 }

3. Kiểm Tra HLS Output

# Xem file được sinh ra
ls -la /data/videos/hls/1/1/
# master.m3u8  360p/  720p/
 
cat /data/videos/hls/1/1/master.m3u8
 
ls /data/videos/hls/1/1/360p/
# playlist.m3u8  segment-000.ts  segment-001.ts  ...

4. Test Phát Lại (Kiểm Tra Nhanh)

Bạn có thể test HLS playback local bằng ffplay:

ffplay /data/videos/hls/1/1/master.m3u8

Hoặc serve bằng Python HTTP server rồi mở trong browser với HLS player extension.


Lỗi Thường Gặp

1. Chạy FFmpeg Đồng Bộ

Không bao giờ chạy FFmpeg trong request thread — nó block HTTP connection hàng phút:

// SAI — block request
@PostMapping("/upload")
public ResponseEntity<?> upload(MultipartFile file) {
    storeFile(file);
    runFFmpeg(file);  // Block 5+ phút!
    return ResponseEntity.ok("Done");
}
 
// ĐÚNG — async background processing
@PostMapping("/upload")
public ResponseEntity<?> upload(MultipartFile file) {
    storeFile(file);
    transcodingService.transcodeVideo(...);  // Trả về ngay
    return ResponseEntity.ok("Transcoding started");
}

2. Không Giới Hạn Thread Pool Size

Unlimited FFmpeg process sẽ giết server:

// SAI — unlimited thread
@Async
public void transcode() { ... }
 
// ĐÚNG — bounded thread pool
@Async("transcodingExecutor")  // Dùng pool đã cấu hình, tối đa 4 thread
public void transcode() { ... }

3. Bỏ Qua FFmpeg Exit Code

FFmpeg trả non-zero exit code khi fail. Luôn kiểm tra:

int exitCode = process.waitFor();
if (exitCode != 0) {
    throw new RuntimeException("FFmpeg failed: exit code " + exitCode);
}

4. Hardcode Resolution

Nếu ai đó upload video 480p, đừng upscale lên 720p:

// SAI — luôn transcode cả hai
profiles.add(new TranscodeProfile("360p", ...));
profiles.add(new TranscodeProfile("720p", ...));
 
// ĐÚNG — kiểm tra source resolution trước
if (sourceHeight >= 720) {
    profiles.add(new TranscodeProfile("720p", ...));
}

Bước Tiếp Theo

HLS file đã trên disk, nhưng chưa có cách serve chúng tới browser. Trong Post #8, chúng ta sẽ setup secure video streaming:

  • Cấu hình Nginx để serve HLS file
  • Module secure_link cho token-based URL validation
  • Signed URL generation trong Spring Boot
  • CORS header cho cross-origin video playback
  • Ngăn chặn direct download video segment

Đã đến lúc serve HLS stream ra thế giới — một cách an toàn.

Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 5: Video Upload & Storage
Tiếp theo: Phase 7: Secure Video Streaming

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.