- 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>
240 lines
6.7 KiB
Go
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)
|
|
}
|