package transcoding import ( "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // Quality represents a transcoding quality profile type Quality struct { Name string `json:"name"` Width int `json:"width"` Height int `json:"height"` Bitrate string `json:"bitrate"` Preset string `json:"preset"` } // Job represents a transcoding job type Job struct { ID string `json:"id"` InputPath string `json:"input_path"` OutputDir string `json:"output_dir"` FileHash string `json:"file_hash"` Qualities []Quality `json:"qualities"` Priority int `json:"priority"` Status string `json:"status"` // "queued", "processing", "completed", "failed" Progress float64 `json:"progress"` Error string `json:"error,omitempty"` StartTime time.Time `json:"start_time"` CompletedAt time.Time `json:"completed_at,omitempty"` Callback func(error) `json:"-"` } // Transcoder handles video transcoding operations type Transcoder struct { workDir string ffmpegPath string concurrent int queue chan Job jobs map[string]*Job // Track job status enabled bool } // DefaultQualities provides standard quality profiles var DefaultQualities = []Quality{ {Name: "1080p", Width: 1920, Height: 1080, Bitrate: "5000k", Preset: "fast"}, {Name: "720p", Width: 1280, Height: 720, Bitrate: "2500k", Preset: "fast"}, {Name: "480p", Width: 854, Height: 480, Bitrate: "1000k", Preset: "fast"}, } // NewTranscoder creates a new transcoder instance func NewTranscoder(workDir string, concurrent int, enabled bool) (*Transcoder, error) { if !enabled { return &Transcoder{ enabled: false, jobs: make(map[string]*Job), }, nil } // Verify FFmpeg is available if _, err := exec.LookPath("ffmpeg"); err != nil { return nil, fmt.Errorf("ffmpeg not found in PATH: %w", err) } // Create work directory if it doesn't exist if err := os.MkdirAll(workDir, 0755); err != nil { return nil, fmt.Errorf("failed to create work directory: %w", err) } t := &Transcoder{ workDir: workDir, ffmpegPath: "ffmpeg", concurrent: concurrent, queue: make(chan Job, 100), jobs: make(map[string]*Job), enabled: true, } // Start worker goroutines for i := 0; i < concurrent; i++ { go t.worker() } return t, nil } // IsEnabled returns whether transcoding is enabled func (t *Transcoder) IsEnabled() bool { return t.enabled } // worker processes jobs from the queue func (t *Transcoder) worker() { for job := range t.queue { t.processJob(job) } } // SubmitJob adds a job to the transcoding queue func (t *Transcoder) SubmitJob(job Job) { if !t.enabled { if job.Callback != nil { job.Callback(fmt.Errorf("transcoding is disabled")) } return } job.Status = "queued" job.StartTime = time.Now() t.jobs[job.ID] = &job t.queue <- job } // GetJobStatus returns the current status of a job func (t *Transcoder) GetJobStatus(jobID string) (*Job, bool) { job, exists := t.jobs[jobID] return job, exists } // NeedsTranscoding checks if a file needs transcoding for web compatibility func (t *Transcoder) NeedsTranscoding(filePath string) (bool, error) { if !t.enabled { return false, nil } // Use ffprobe to analyze the file cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath) output, err := cmd.Output() if err != nil { return true, err // Assume needs transcoding if we can't analyze } outputStr := string(output) // Check if already in web-friendly format hasH264 := strings.Contains(outputStr, "\"codec_name\": \"h264\"") hasAAC := strings.Contains(outputStr, "\"codec_name\": \"aac\"") isMP4 := strings.HasSuffix(strings.ToLower(filePath), ".mp4") // If it's H.264/AAC in MP4 container, probably doesn't need transcoding if hasH264 && hasAAC && isMP4 { return false, nil } return true, nil } // CreateStreamingVersion creates a single web-compatible MP4 version func (t *Transcoder) CreateStreamingVersion(inputPath, outputPath string) error { if !t.enabled { return fmt.Errorf("transcoding is disabled") } // Ensure output directory exists if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } args := []string{ "-i", inputPath, "-c:v", "libx264", // H.264 video codec "-c:a", "aac", // AAC audio codec "-preset", "fast", // Balance speed/quality "-crf", "23", // Quality (23 is default, lower = better) "-maxrate", "2M", // Max bitrate for streaming "-bufsize", "4M", // Buffer size "-movflags", "+faststart", // Enable fast start for web "-y", // Overwrite output file outputPath, } cmd := exec.Command(t.ffmpegPath, args...) return cmd.Run() } // processJob handles the actual transcoding work func (t *Transcoder) processJob(job Job) { job.Status = "processing" t.jobs[job.ID] = &job var err error defer func() { if err != nil { job.Status = "failed" job.Error = err.Error() } else { job.Status = "completed" job.Progress = 100.0 } job.CompletedAt = time.Now() t.jobs[job.ID] = &job if job.Callback != nil { job.Callback(err) } }() // Create output directory if err = os.MkdirAll(job.OutputDir, 0755); err != nil { err = fmt.Errorf("failed to create output directory: %w", err) return } // Create streaming MP4 version (most important for web compatibility) outputPath := filepath.Join(job.OutputDir, "stream.mp4") err = t.CreateStreamingVersion(job.InputPath, outputPath) if err != nil { err = fmt.Errorf("transcoding failed: %w", err) return } job.Progress = 100.0 } // GetTranscodedPath returns the path to a transcoded file if it exists func (t *Transcoder) GetTranscodedPath(fileHash string) string { if !t.enabled { return "" } streamPath := filepath.Join(t.workDir, fileHash, "stream.mp4") if _, err := os.Stat(streamPath); err == nil { return streamPath } return "" } // Close shuts down the transcoder func (t *Transcoder) Close() { if t.enabled && t.queue != nil { close(t.queue) } }