infoalloggi-monorepo/apps/backend/video.go

412 lines
11 KiB
Go
Raw Normal View History

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"
)
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) {
var (
ProcessedVideos []typesdefs.ProcessedMedia
UploadedVideos []typesdefs.UploadedMedia
mu sync.Mutex
wg sync.WaitGroup
)
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,
})
mu.Unlock()
}(video)
}
wg.Wait()
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,
})
mu.Unlock()
}(video)
}
uploadWg.Wait()
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
}