package main import ( "backend/config" "backend/queries" "backend/typesdefs" "backend/utils" "fmt" "image/jpeg" "io" "log" "net/http" "os" "os/exec" "path/filepath" "strings" "sync" "time" "github.com/labstack/echo/v4" "github.com/nfnt/resize" "github.com/nickalie/go-webpbin" "github.com/schollz/progressbar/v3" ) func AddToVideoProcessing(tmp *typesdefs.AnnuncioParsed, annuncio *typesdefs.AnnuncioXML, p *[]typesdefs.MediaToProcess) { miogestVideos := []string{} if annuncio.AMMedias != nil { for _, media := range *annuncio.AMMedias { if media.AMVideo != nil && media.AMVideo.Url != nil { if strings.Contains(*media.AMVideo.Url, "video.miogest") { miogestVideos = append(miogestVideos, *media.AMVideo.Url) } } } } for idx, url := range miogestVideos { *p = append(*p, typesdefs.MediaToProcess{ Codice: tmp.Codice, Url: url, Order: idx, }) } } func ProcessVideoPool(toProcess *[]typesdefs.MediaToProcess) ([]typesdefs.UploadedMedia, error) { if len(*toProcess) == 0 { log.Println("No new videos to process.") return []typesdefs.UploadedMedia{}, nil } var ( ProcessedVideos []typesdefs.ProcessedMedia UploadedVideos []typesdefs.UploadedMedia mu sync.Mutex wg sync.WaitGroup ) totalToProcess := len(*toProcess) bar := progressbar.Default(int64(totalToProcess), "Processing videos") sem := make(chan struct{}, config.Cfg.Videos.ConcurrentLimit) // clear videos folder 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 videoPath := filepath.Join(config.Cfg.Videos.Path, fmt.Sprintf("%s_%d%s", video.Codice, video.Order, filepath.Ext(video.Url))) 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 } // Transcode and generate thumbnail mp4Path, thumbnailPath, err := VideoConversion(videoPath) if err != nil { log.Printf("Failed to convert video %s: %v", videoPath, err) return } mu.Lock() ProcessedVideos = append(ProcessedVideos, typesdefs.ProcessedMedia{ Codice: video.Codice, Order: video.Order, Path: mp4Path, ThumnailPath: thumbnailPath, OGUrl: video.Url, }) bar.Add(1) mu.Unlock() }(video) } wg.Wait() bar.Finish() fmt.Println() totalToUpload := len(ProcessedVideos) uploadBar := progressbar.Default(int64(totalToUpload), "Uploading videos") uploadSem := make(chan struct{}, config.Cfg.Videos.ConcurrentLimit) var uploadWg sync.WaitGroup for _, video := range ProcessedVideos { uploadSem <- struct{}{} // acquire semaphore uploadWg.Add(1) go func(video typesdefs.ProcessedMedia) { defer uploadWg.Done() defer func() { <-uploadSem }() // release semaphore videoId, err := UploadFile(video.Path, "video/mp4", "videos") if err != nil { log.Printf("failed to upload video: %v", err) return } thumbId, err := UploadFile(video.ThumnailPath, "image/webp", "videos") if err != nil { log.Printf("failed to upload thumbnail: %v", err) return } mu.Lock() UploadedVideos = append(UploadedVideos, typesdefs.UploadedMedia{ Codice: video.Codice, Order: video.Order, MediaId: videoId, ThumbId: thumbId, OGUrl: video.OGUrl, }) uploadBar.Add(1) mu.Unlock() }(video) } uploadWg.Wait() uploadBar.Finish() fmt.Println() return UploadedVideos, 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)] tempVideo := filepath.Join(outputdir, nameWithoutExt+"_temp.mp4") videoOutput := filepath.Join(outputdir, nameWithoutExt+".mp4") thumbOutput := filepath.Join(outputdir, "thumbnail-"+nameWithoutExt+".webp") // 1. Transcode video to H.264/AAC MP4 (web-optimized) //log.Println("Transcoding video:", input) err := transcodeVideo(input, tempVideo) if err != nil { return "", "", fmt.Errorf("failed to transcode video: %w", err) } // 2. Extract thumbnail from video at 1 second //log.Println("Extracting thumbnail from:", input) err = extractThumbnail(input, thumbOutput) if err != nil { return "", "", fmt.Errorf("failed to extract thumbnail: %w", err) } os.Remove(input) err = os.Rename(tempVideo, videoOutput) if err != nil { return "", "", fmt.Errorf("failed to rename converted video: %w", err) } return videoOutput, thumbOutput, nil } // transcodeVideo converts video to web-optimized MP4 func transcodeVideo(input, output string) error { // FFmpeg command for web optimization: // -c:v libx264: H.264 codec (widely supported) // -preset fast: encoding speed/quality tradeoff // -crf 23: quality (lower = better, 18-28 recommended) // -c:a aac: AAC audio codec // -b:a 128k: audio bitrate // -movflags +faststart: enable streaming before full download // -vf scale=1280:-2: scale to max width 1280, maintain aspect ratio 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", // Escape comma for cross-platform "-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 } func FallbackVideo(c echo.Context) error { // Serve a fallback image if the requested image is not found fallbackPath := "fallback.webp" // Path to your fallback image if _, err := os.Stat(fallbackPath); os.IsNotExist(err) { return c.String(http.StatusNotFound, "Fallback image not found") } return c.File(fallbackPath) } type videRef struct { Og_url string Video string Thumb string } func CodiciToProcessVideos(annunci *typesdefs.AnnunciXML) ([]string, error) { currentBucketState, err := ListBucket("videos") if err != nil { return nil, fmt.Errorf("failed to list bucket: %w", err) } //make a list of bucket ids bucketIdsMap := make(map[string]bool) for _, item := range currentBucketState { bucketIdsMap[item.ID] = true } db_videos, err := queries.GetVideosFromDB() if err != nil { return nil, err } dbVideosMap := make(map[string][]videRef) // Convert slice to map for faster lookup for _, ref := range db_videos { dbVideosMap[ref.Codice] = append(dbVideosMap[ref.Codice], videRef{ Og_url: ref.Og_url, Video: ref.Video, Thumb: ref.Thumb, }) } var codiciToProcess []string for _, annuncio := range annunci.Annuncio { if annuncio.Codice != nil && annuncio.AMMedias != nil && len(*annuncio.AMMedias) > 0 { mediaUrls := []string{} for _, media := range *annuncio.AMMedias { if media.AMVideo != nil && media.AMVideo.Url != nil { if strings.Contains(*media.AMVideo.Url, "video.miogest") { mediaUrls = append(mediaUrls, *media.AMVideo.Url) } } } if len(mediaUrls) == 0 { continue } refVideos, existsInDb := dbVideosMap[*annuncio.Codice] if !existsInDb { codiciToProcess = append(codiciToProcess, *annuncio.Codice) continue } needProcess := false for _, media := range *annuncio.AMMedias { if media.AMVideo == nil || media.AMVideo.Url == nil { continue } if !strings.Contains(*media.AMVideo.Url, "video.miogest") { continue } found := false for _, ref := range refVideos { if *media.AMVideo.Url == ref.Og_url { if bucketIdsMap[ref.Video] && bucketIdsMap[ref.Thumb] { found = true break } } } if !found { needProcess = true break } } if needProcess { codiciToProcess = append(codiciToProcess, *annuncio.Codice) } } } return codiciToProcess, nil } func ForceClearVideos(codici []string) error { var idsToDelete []string for _, codice := range codici { refs, err := queries.GetCodiceVideosFromDB(codice) if err != nil { return fmt.Errorf("failed to get videos by codice %s: %w", codice, err) } for _, ref := range refs { idsToDelete = append(idsToDelete, ref.Video) idsToDelete = append(idsToDelete, ref.Thumb) } } err := DeleteIdsFromStorage(idsToDelete) if err != nil { return fmt.Errorf("failed to delete videos from storage: %w", err) } for _, codice := range codici { err := queries.ClearVideosRefsByCodice(codice) if err != nil { return fmt.Errorf("failed to clear videos by codice %s: %w", codice, err) } } return nil }