veza/veza-backend-api/internal/services/video_transcode_service.go
senke 02d1846141
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s
feat(v0.12.3): F276-F305 video upload, HLS transcoding, education tests
- Add video upload endpoint POST /courses/:id/lessons/:lesson_id/video
- Add VideoTranscodeService for multi-bitrate HLS (720p/480p/360p)
- Add VideoTranscodeWorker for async lesson video processing
- Add SetLessonVideoPath and UpdateLessonTranscoding to education service
- Add uploadLessonVideo to frontend educationService with progress
- Add comprehensive handler tests (video upload, auth, validation)
- Add service-level tests (models, slugs, clamping, errors, UUIDs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:20:48 +01:00

240 lines
6.7 KiB
Go

package services
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"veza-backend-api/internal/utils"
"github.com/google/uuid"
"go.uber.org/zap"
)
// VideoQuality defines a video transcoding quality preset
type VideoQuality struct {
Name string // e.g. "720p", "480p", "360p"
Resolution string // e.g. "1280x720"
Bitrate string // e.g. "2500k"
AudioRate string // e.g. "128k"
}
// VideoTranscodeResult holds the result of a video transcoding job
type VideoTranscodeResult struct {
LessonID uuid.UUID
MasterPlaylistURL string
DurationSeconds int
}
// DefaultVideoQualities returns the default multi-bitrate quality presets
func DefaultVideoQualities() []VideoQuality {
return []VideoQuality{
{Name: "720p", Resolution: "1280x720", Bitrate: "2500k", AudioRate: "128k"},
{Name: "480p", Resolution: "854x480", Bitrate: "1000k", AudioRate: "96k"},
{Name: "360p", Resolution: "640x360", Bitrate: "500k", AudioRate: "64k"},
}
}
// VideoTranscodeService handles HLS transcoding for course lesson videos
type VideoTranscodeService struct {
outputDir string
qualities []VideoQuality
logger *zap.Logger
}
// NewVideoTranscodeService creates a new video transcoding service
func NewVideoTranscodeService(outputDir string, logger *zap.Logger) *VideoTranscodeService {
if logger == nil {
logger = zap.NewNop()
}
return &VideoTranscodeService{
outputDir: outputDir,
qualities: DefaultVideoQualities(),
logger: logger,
}
}
// SetQualities configures the quality presets for transcoding
func (s *VideoTranscodeService) SetQualities(qualities []VideoQuality) {
s.qualities = qualities
}
// TranscodeVideo transcodes a video file into HLS multi-bitrate format
func (s *VideoTranscodeService) TranscodeVideo(ctx context.Context, lessonID uuid.UUID, inputPath string) (*VideoTranscodeResult, error) {
if inputPath == "" {
return nil, fmt.Errorf("input video path is empty")
}
if _, err := os.Stat(inputPath); os.IsNotExist(err) {
return nil, fmt.Errorf("video file does not exist: %s", inputPath)
}
lessonDir := filepath.Join(s.outputDir, fmt.Sprintf("lesson_%s", lessonID))
if err := os.MkdirAll(lessonDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create lesson directory: %w", err)
}
var cleanupErr error
defer func() {
if cleanupErr != nil {
if err := os.RemoveAll(lessonDir); err != nil {
s.logger.Error("Failed to cleanup lesson directory", zap.Error(err))
}
}
}()
// Transcode each quality variant
for _, quality := range s.qualities {
if err := s.transcodeQuality(ctx, inputPath, lessonDir, quality); err != nil {
cleanupErr = err
return nil, fmt.Errorf("failed to transcode %s: %w", quality.Name, err)
}
s.logger.Info("Transcoded video quality",
zap.String("quality", quality.Name),
zap.String("lesson_id", lessonID.String()),
)
}
// Generate master playlist
masterPath := filepath.Join(lessonDir, "master.m3u8")
if err := s.generateMasterPlaylist(lessonDir); err != nil {
cleanupErr = err
return nil, fmt.Errorf("failed to generate master playlist: %w", err)
}
// Get video duration via ffprobe
duration := s.probeDuration(ctx, inputPath)
return &VideoTranscodeResult{
LessonID: lessonID,
MasterPlaylistURL: masterPath,
DurationSeconds: duration,
}, nil
}
// transcodeQuality transcodes the video for a specific quality preset
func (s *VideoTranscodeService) transcodeQuality(ctx context.Context, inputPath, outputDir string, quality VideoQuality) error {
qualityDir := filepath.Join(outputDir, quality.Name)
if err := os.MkdirAll(qualityDir, 0755); err != nil {
return fmt.Errorf("failed to create quality directory: %w", err)
}
segmentPattern := filepath.Join(qualityDir, "segment_%03d.ts")
playlistPath := filepath.Join(qualityDir, "playlist.m3u8")
// Validate paths for exec.Command
if !utils.ValidateExecPath(inputPath) || !utils.ValidateExecPath(playlistPath) {
return fmt.Errorf("invalid file path")
}
cmd := exec.CommandContext(ctx, "ffmpeg",
"-i", inputPath,
"-vf", fmt.Sprintf("scale=%s", quality.Resolution),
"-c:v", "libx264",
"-preset", "medium",
"-b:v", quality.Bitrate,
"-c:a", "aac",
"-b:a", quality.AudioRate,
"-hls_time", "10",
"-hls_playlist_type", "vod",
"-hls_segment_filename", segmentPattern,
"-hls_list_size", "0",
"-y",
playlistPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
s.logger.Error("FFmpeg video transcoding failed",
zap.String("quality", quality.Name),
zap.String("output", string(output)),
zap.Error(err),
)
return fmt.Errorf("ffmpeg failed: %w", err)
}
if _, err := os.Stat(playlistPath); os.IsNotExist(err) {
return fmt.Errorf("playlist file was not created: %s", playlistPath)
}
return nil
}
// generateMasterPlaylist creates the master HLS playlist referencing all quality variants
func (s *VideoTranscodeService) generateMasterPlaylist(lessonDir string) error {
masterPath := filepath.Join(lessonDir, "master.m3u8")
bandwidthMap := map[string]int{
"720p": 2628000,
"480p": 1128000,
"360p": 628000,
}
resolutionMap := map[string]string{
"720p": "1280x720",
"480p": "854x480",
"360p": "640x360",
}
var lines []string
lines = append(lines, "#EXTM3U")
lines = append(lines, "#EXT-X-VERSION:3")
for _, quality := range s.qualities {
bandwidth := bandwidthMap[quality.Name]
if bandwidth == 0 {
bandwidth = 1000000
}
resolution := resolutionMap[quality.Name]
playlistPath := filepath.Join(quality.Name, "playlist.m3u8")
lines = append(lines, fmt.Sprintf(
"#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%s,CODECS=\"avc1.64001f,mp4a.40.2\"",
bandwidth, resolution,
))
lines = append(lines, playlistPath)
}
content := strings.Join(lines, "\n") + "\n"
if err := os.WriteFile(masterPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write master playlist: %w", err)
}
return nil
}
// probeDuration uses ffprobe to get video duration in seconds
func (s *VideoTranscodeService) probeDuration(ctx context.Context, inputPath string) int {
if !utils.ValidateExecPath(inputPath) {
return 0
}
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
inputPath,
)
output, err := cmd.Output()
if err != nil {
s.logger.Warn("ffprobe failed to get duration", zap.Error(err))
return 0
}
var duration float64
if _, err := fmt.Sscanf(strings.TrimSpace(string(output)), "%f", &duration); err != nil {
return 0
}
return int(duration)
}
// CleanupLessonDir removes the transcoded files for a lesson
func (s *VideoTranscodeService) CleanupLessonDir(lessonID uuid.UUID) error {
lessonDir := filepath.Join(s.outputDir, fmt.Sprintf("lesson_%s", lessonID))
return os.RemoveAll(lessonDir)
}