package main import ( "backend/config" models "backend/gen/postgres/postgres/public/model" "backend/queries" "backend/typesdefs" "backend/utils" "fmt" "image/jpeg" "io" "log" "net/http" "os" "os/exec" "path/filepath" "strings" "sync" "time" "github.com/nfnt/resize" "github.com/nickalie/go-webpbin" ) func AddToVideoProcessing(tmp *typesdefs.AnnuncioParsed, annuncio *typesdefs.AnnuncioXML, p *[]typesdefs.MediaToProcess) { miogestVideos := []string{} if annuncio.AMMedias != nil { for _, media := range annuncio.AMMedias.AMVideo { if media.Url != nil { if strings.Contains(*media.Url, "video.miogest") { miogestVideos = append(miogestVideos, *media.Url) } } } } for idx, url := range miogestVideos { *p = append(*p, typesdefs.MediaToProcess{ Codice: tmp.Codice, Url: url, Order: idx, }) } } func ProcessVideoPool(toProcess *[]typesdefs.MediaToProcess, isDryRun bool) error { if len(*toProcess) == 0 { log.Println("No new videos to process.") return nil } var wg sync.WaitGroup totalToProcess := len(*toProcess) log.Printf("Processing %d videos with concurrency limit %d", totalToProcess, config.Cfg.Videos.ConcurrentLimit) sem := make(chan struct{}, config.Cfg.Videos.ConcurrentLimit) // clear videos folder if isDryRun { log.Println("Dry run. Clearing videos folder.") } else { err := clearVideoFolder() if err != nil { log.Fatalf("Failed to initialize image processing: %v", err) } } // process videos for _, video := range *toProcess { sem <- struct{}{} // acquire semaphore wg.Add(1) go func(video typesdefs.MediaToProcess) { defer wg.Done() defer func() { <-sem }() // release semaphore if isDryRun { log.Printf("Dry run. video processing: %s_%d", video.Codice, video.Order) return } log.Printf("%s_%d: elaborating", video.Codice, video.Order) videoPath := filepath.Join(config.Cfg.Videos.Path, fmt.Sprintf("%s_%d%s", video.Codice, video.Order, filepath.Ext(video.Url))) log.Printf("%s_%d: download", video.Codice, video.Order) err := downloadVideo(video.Url, videoPath) if err != nil { log.Printf("Failed to download video %s: %v", video.Url, err) return // Exit goroutine on download failure } log.Printf("%s_%d: conversion", video.Codice, video.Order) mp4Path, thumbnailPath, err := VideoConversion(videoPath) if err != nil { log.Printf("Failed to convert video %s: %v", videoPath, err) return } log.Printf("%s_%d: upload", video.Codice, video.Order) videoId, err := queries.UploadFile(mp4Path, "video/mp4", "videos") if err != nil { log.Printf("failed to upload video: %v", err) return } thumbId, err := queries.UploadFile(thumbnailPath, "image/webp", "videos") if err != nil { log.Printf("failed to upload thumbnail: %v", err) return } log.Printf("%s_%d: videos_refs upd", video.Codice, video.Order) err = queries.SetMediaToDB(models.MediaRefs{ Codice: video.Codice, Ordine: int32(video.Order), OgURL: video.Url, MediaType: "video", StorageID: videoId, IsThumbnail: false, }) if err != nil { log.Printf("failed to set media to db: %v", err) return } err = queries.SetMediaToDB(models.MediaRefs{ Codice: video.Codice, Ordine: int32(video.Order), OgURL: video.Url, MediaType: "video", StorageID: thumbId, IsThumbnail: true, }) if err != nil { log.Printf("failed to set media to db: %v", err) return } log.Printf("%s_%d: done", video.Codice, video.Order) }(video) } wg.Wait() log.Println("Uploaded all videos.") return nil } func clearVideoFolder() error { err := utils.SafeRemoveAll(config.Cfg.Videos.Path) if err != nil { return err } err = os.Mkdir(config.Cfg.Videos.Path, 0755) if err != nil { return err } return nil } func downloadVideo(url, filename string) error { client := &http.Client{ Timeout: 5 * time.Minute, // Adjust based on expected video sizes } response, err := client.Get(url) if err != nil { return fmt.Errorf("failed to download video: %w", err) } defer response.Body.Close() // Check HTTP status if response.StatusCode != http.StatusOK { return fmt.Errorf("failed to download video: HTTP %d", response.StatusCode) } // Optional: Validate Content-Type contentType := response.Header.Get("Content-Type") if !strings.HasPrefix(contentType, "video/") && !strings.HasPrefix(contentType, "application/") { log.Printf("Warning: unexpected content type %s for %s", contentType, url) } file, err := os.Create(filename) if err != nil { return fmt.Errorf("failed to create video file: %w", err) } defer file.Close() _, err = io.Copy(file, response.Body) if err != nil { return fmt.Errorf("failed to save video file: %w", err) } return nil } // VideoConversion transcodes video and generates thumbnail func VideoConversion(input string) (string, string, error) { outputdir := filepath.Dir(input) basename := filepath.Base(input) ext := filepath.Ext(basename) nameWithoutExt := basename[0 : len(basename)-len(ext)] // Final destinations on the (potentially overlayfs) volume videoOutput := filepath.Join(outputdir, nameWithoutExt+".mp4") thumbOutput := filepath.Join(outputdir, "thumbnail-"+nameWithoutExt+".webp") // Use /tmp for all ffmpeg work: /tmp is tmpfs and supports random-access // seeks that overlayfs does not, which is required for MP4 muxing and // for the faststart moov-atom relocation. tmpVideo := filepath.Join(os.TempDir(), nameWithoutExt+"_converted.mp4") tmpThumb := filepath.Join(os.TempDir(), "thumbnail-"+nameWithoutExt+".webp") defer os.Remove(tmpVideo) defer os.Remove(tmpThumb) // 1. Transcode video to H.264/AAC MP4 (web-optimized) into /tmp err := transcodeVideo(input, tmpVideo) if err != nil { return "", "", fmt.Errorf("failed to transcode video: %w", err) } // 2. Extract thumbnail from original video into /tmp err = extractThumbnail(input, tmpThumb) if err != nil { return "", "", fmt.Errorf("failed to extract thumbnail: %w", err) } // 3. Move completed files from /tmp to the videos volume os.Remove(input) if err = copyFile(tmpVideo, videoOutput); err != nil { return "", "", fmt.Errorf("failed to move converted video: %w", err) } if err = copyFile(tmpThumb, thumbOutput); err != nil { return "", "", fmt.Errorf("failed to move thumbnail: %w", err) } return videoOutput, thumbOutput, nil } // copyFile copies src to dst, creating dst if needed. func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() if _, err = io.Copy(out, in); err != nil { return err } return out.Sync() } // transcodeVideo converts video to web-optimized MP4. // output must be on a filesystem that supports random-access seeks (e.g. /tmp), // because the MP4 muxer and -movflags +faststart both require re-opening the // output file to relocate the moov atom — this fails on Docker overlayfs. func transcodeVideo(input, output string) error { cmd := exec.Command("ffmpeg", "-i", input, "-c:v", "libx264", "-preset", "fast", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-vf", "scale=min(1280\\,iw):-2", "-y", output, ) output_bytes, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("ffmpeg transcode failed: %w, output: %s", err, string(output_bytes)) } return nil } // extractThumbnail generates a WebP thumbnail from video func extractThumbnail(input, output string) error { // Extract frame at 1 second as JPEG to temp file tempJpeg := output + ".tmp.jpg" defer os.Remove(tempJpeg) cmd := exec.Command("ffmpeg", "-i", input, "-ss", "00:00:01", // seek to 1 second "-vframes", "1", // extract 1 frame "-q:v", "2", // JPEG quality (2-31, lower = better) "-y", tempJpeg, ) output_bytes, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("ffmpeg thumbnail extraction failed: %w, output: %s", err, string(output_bytes)) } // Convert JPEG to WebP thumbnail f, err := os.Open(tempJpeg) if err != nil { return fmt.Errorf("failed to open temp jpeg: %w", err) } defer f.Close() img, err := jpeg.Decode(f) if err != nil { return fmt.Errorf("failed to decode jpeg: %w", err) } // Resize to thumbnail (320px width) resizedImg := resize.Resize(320, 0, img, resize.Lanczos3) out, err := os.Create(output) if err != nil { return fmt.Errorf("failed to create thumbnail: %w", err) } defer out.Close() err = webpbin.Encode(out, resizedImg) if err != nil { return fmt.Errorf("failed to encode webp: %w", err) } return nil }