CCCPaste

1113a25273245

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"os/exec"
	"strings"
	"time"

	"github.com/aws/aws-sdk-go/aws/credentials"

	"math/rand"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/gin-gonic/gin"
	"github.com/joho/godotenv"
	ffmpeg "github.com/u2takey/ffmpeg-go"
)

const CLOUD_PREFIX string = "https://s.repetitor.ru"
const OUTPUT_CLOUD_PREFIX string = "https://s.repetitor.ru/video/encode/"
const RELATIVE_CLOUD_PATH string = "/video/encode/"
const VIDEO_WIDTH = 1280
const VIDEO_HEIGHT = 720

// VideoMetadata содержит информацию о ширине и высоте видео
type VideoMetadata struct {
	Streams []struct {
		Width  int `json:"width"`
		Height int `json:"height"`
	} `json:"streams"`
}

type RequestBody struct {
	Url string
}

const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func randomString(n int) string {
	sb := strings.Builder{}
	sb.Grow(n)
	for i := 0; i < n; i++ {
		sb.WriteByte(charset[rand.Intn(len(charset))])
	}
	return sb.String()
}

func main() {
	rand.Seed(time.Now().UnixNano())
	// Load .env file
	SetConfig()

	// Retrieve configurations from environment variables
	accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
	secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
	region := os.Getenv("AWS_DEFAULT_REGION")
	endpoint := os.Getenv("AWS_ENDPOINT_URL")
	bucketName := os.Getenv("AWS_S3_BUCKET_NAME")
	mode := os.Getenv("GIN_MODE")
	port := os.Getenv("SERVER_PORT")

	// Validate required AWS configurations
	if accessKey == "" || secretKey == "" || region == "" || endpoint == "" || bucketName == "" {
		log.Fatal("Missing required environment variables for AWS configuration")
	}

	// Set Gin mode
	if mode == "release" {
		gin.SetMode(gin.ReleaseMode)
	} else {
		gin.SetMode(gin.DebugMode)
	}

	// Default port if not set
	if port == "" {
		port = "8080"
	}

	// Create AWS session with custom endpoint
	cfg := &aws.Config{
		Region:      aws.String(region),
		Endpoint:    aws.String(endpoint),
		Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""),
	}
	sess, err := session.NewSession(cfg)
	if err != nil {
		log.Fatalf("Failed to create AWS session: %v", err)
	}

	client := s3.New(sess)

	// Router setup
	router := gin.Default()
	router.GET("/ping", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{
			"message": "pong",
		})
	})

	router.POST("/decode/", AuthMiddleware(),
		func(c *gin.Context) {
			var requestBody RequestBody

			if err := c.BindJSON(&requestBody); err != nil {
				log.Fatalf("Can't bind request body: %s", err)
			}

			url, _ := strings.CutPrefix(
				requestBody.Url,
				CLOUD_PREFIX,
			)
			splitUrl := strings.Split(url, "/")
			filename := splitUrl[len(splitUrl)-1]
			log.Printf("url: %s", url)
			log.Printf("filename: %s", filename)

			if url == "" {
				c.JSON(http.StatusBadRequest, gin.H{
					"message": "Url is required",
				})
				return
			}
			log.Printf("Decoding video: %s...", url)
			outputFilename := CodeVideo(client, url, filename)
			log.Printf("Work Done!")
			c.IndentedJSON(http.StatusCreated, gin.H{
				"url": fmt.Sprintf("%s%s", OUTPUT_CLOUD_PREFIX, outputFilename),
			})
		})

	log.Printf("Server started on port: %s, in mode: %s", port, mode)

	router.Run()
}

func SetConfig() {
	err := godotenv.Load()
	if err != nil {
		log.Fatalf("Config file not found with error: %s", err)
	}

	log.Printf("Config file loaded: %s", ".env")
}

// AuthMiddleware проверяет secret-key из заголовка Authorization
func AuthMiddleware() gin.HandlerFunc {
	secretKey := os.Getenv("SECRET_KEY")
	if secretKey == "" {
		log.Fatal("Secret key not found")
	}
	return func(c *gin.Context) {
		// Извлекаем заголовок Authorization
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"})
			c.Abort()
			return
		}

		// Проверяем, совпадает ли ключ
		if authHeader != secretKey {
			c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid secret key"})
			c.Abort()
			return
		}

		// Если ключ валиден, продолжаем выполнение
		c.Next()
	}
}

// GetVideo загружает видео из S3 и сохраняет его во временный файл
func GetVideo(client *s3.S3, url, filename string) string {
	bucket := os.Getenv("AWS_S3_BUCKET_NAME")
	response, err := client.GetObject(&s3.GetObjectInput{
		Bucket: aws.String(bucket),
		Key:    aws.String(fmt.Sprintf("/%s", url)),
	})
	if err != nil {
		log.Fatalf("Failed to download file from S3: %v", err)
	}
	defer func(Body io.ReadCloser) {
		err := Body.Close()
		if err != nil {
			log.Fatalf("Failed to close response body: %v", err)
		}
	}(response.Body)

	tempFile, err := os.Create(fmt.Sprintf("downloaded_%s_%s", randomString(5), filename))
	if err != nil {
		log.Fatalf("Failed to create temporary file: %v", err)
	}
	defer func(tempFile *os.File) {
		err := tempFile.Close()
		if err != nil {
			log.Fatalf("Failed to close temporary file: %v", err)
		}
	}(tempFile)

	_, err = io.Copy(tempFile, response.Body)
	if err != nil {
		log.Fatalf("Failed to save file locally: %v", err)
	}

	log.Printf("File downloaded and saved to: %s", tempFile.Name())

	return tempFile.Name()
}

// UploadVideo загружает обработанное видео обратно в S3
func UploadVideo(client *s3.S3, localFile string) {
	bucket := os.Getenv("AWS_S3_BUCKET_NAME")

	// Открываем локальный файл
	file, err := os.Open(localFile)
	if err != nil {
		log.Fatalf("Failed to open local file: %v", err)
	}
	defer func(file *os.File) {
		err := file.Close()
		if err != nil {
			log.Fatalf("Failed to close local file: %v", err)
		}
	}(file) // Закрываем файл после завершения функции

	// Загружаем файл в S3 напрямую из потока
	_, err = client.PutObject(&s3.PutObjectInput{
		Bucket:             aws.String(bucket),
		Key:                aws.String(fmt.Sprintf("%s%s", RELATIVE_CLOUD_PATH, localFile)),
		Body:               file, // Передаем поток файла напрямую
		ContentType:        aws.String(http.DetectContentType([]byte(localFile))),
		ContentDisposition: aws.String("attachment"),
	})
	if err != nil {
		log.Fatalf("Failed to upload file to S3: %v", err)
	}

	log.Printf("File uploaded to S3 with key: video/encode/%s", localFile)
}

func splitByLastDot(s string) string {
	lastDotIndex := strings.LastIndex(s, ".")

	if lastDotIndex == -1 {
		return s
	}

	firstPart := s[:lastDotIndex]

	return firstPart
}

// CodeVideo обрабатывает видео и сохраняет его обратно в S3
func CodeVideo(client *s3.S3, url, filename string) string {
	// Скачиваем видео из S3
	inputFile := GetVideo(client, url, filename)
	// Создаем временный файл для обработанного видео
	outputFile, err := os.Create(filename)
	if err != nil {
		log.Fatalf("Failed to create temporary output file: %v", err)
		return ""
	}
	outputFileName := splitByLastDot(outputFile.Name()) + randomString(5) + ".mp4"
	// Извлекаем ширину и высоту видео
	// width, height, err := getVideoDimensions()
	if err != nil {
		log.Fatalf("Ошибка извлечения размеров видео: %v", err)
		return ""
	}
	cropParams, err := detectCropParams(inputFile)
	if err != nil {
		log.Printf("Crop detection failed: %v. Proceeding without cropping.", err)
		return ""
	} else {
		log.Printf("Detected crop parameters: %s", cropParams)
	}

	croppedFile := "cropped_temp" + randomString(5) + ".mp4"
	if cropParams != "" {
		err = ffmpeg.Input(inputFile).
			Filter("crop", ffmpeg.Args{cropParams}).
			Output(croppedFile, ffmpeg.KwArgs{"c:v": "libx264", "c:a": "copy", "map": "0:a"}).
			OverWriteOutput().
			Run()
		if err != nil {
			log.Fatalf("Не удалось применить обрезку: %v", err)
			return ""
		}
		log.Println("Видео успешно обрезано и сохранено во временный файл:", croppedFile)
	} else {
		// Если обрезка не требуется, используем исходный файл
		croppedFile = inputFile
	}

	width, height, err := getVideoDimensions(croppedFile)
	if err != nil {
		log.Fatalf("Video Resolution can't be got, err: %s", err)
		return ""
	}
	// Рассчитываем фильтр масштабирования
	log.Printf("resolution: %d:%d", width, height)
	scaleFilter := calculateScaleFilter(width, height)
	err = ffmpeg.Input(croppedFile).
		Filter("scale", ffmpeg.Args{scaleFilter}).
		Output(outputFileName, ffmpeg.KwArgs{
			"c:v":      "libx264",     // H.264 codec
			"pix_fmt":  "yuv420p",     // compatible pixel format
			"preset":   "fast",
			"movflags": "+faststart",  // essential for browser playback
			"c:a":      "aac",         // re-encode audio to AAC
			"map":      "0:a",
			"b:a":      "128k",        // audio bitrate
		}).
		OverWriteOutput().
		Run()
	if err != nil {
		log.Fatalf("Video encoding error: %v", err)
		return ""
	}

	log.Printf("Encoding completed successfully. Processed file saved to: %s", outputFileName)

	// Загружаем обработанное видео обратно в S3
	UploadVideo(client, outputFileName)

	// Удаляем временные файлы
	err = os.Remove(inputFile)
	if err != nil {
		log.Printf("Failed to remove temporary input file: %v", err)
		return ""
	}

	err = os.Remove(outputFileName)
	if err != nil {
		log.Printf("Failed to remove temporary output file: %v", err)
		return ""
	}

	// Удаляем временный файл (если он был создан)
	if cropParams != "" {
		err = exec.Command("rm", croppedFile).Run()
		if err != nil {
			log.Printf("Не удалось удалить временный файл: %v", err)
			return ""
		} else {
			log.Println("Временный файл удален:", croppedFile)
		}
	}

	return outputFileName
}

// Function to detect crop parameters using ffmpeg's cropdetect filter
func detectCropParams(inputFile string) (string, error) {
	// Run ffmpeg to detect crop parameters
	cmd := exec.Command(
		"ffmpeg",
		"-i", inputFile,
		"-vf", "cropdetect",
		"-f", "null", "-",
	)

	// Capture the output
	output, err := cmd.CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("failed to detect crop parameters: %v", err)
	}

	// Parse the output to extract crop parameters
	cropParams := parseCropParams(string(output))
	if cropParams == "" {
		return "", fmt.Errorf("no crop parameters detected")
	}

	return cropParams, nil
}

// Function to parse crop parameters from ffmpeg output
func parseCropParams(output string) string {
	lines := strings.Split(output, "\n")
	for _, line := range lines {
		if strings.Contains(line, "crop=") {
			parts := strings.Split(line, "crop=")
			if len(parts) > 1 {
				return strings.TrimSpace(parts[1])
			}
		}
	}
	return ""
}

// getVideoDimensions использует ffprobe для получения размеров видео
func getVideoDimensions(inputFile string) (int, int, error) {
	cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
		"stream=width,height", "-of", "json", inputFile)

	var out bytes.Buffer
	cmd.Stdout = &out
	err := cmd.Run()
	if err != nil {
		log.Fatalf("ffprobe error: %s", err)
	}

	// Парсим JSON-ответ
	var metadata VideoMetadata
	if err := json.Unmarshal(out.Bytes(), &metadata); err != nil {
		log.Fatalf("parse JSON error: %s", err)
	}

	// Проверяем, что данные о ширине и высоте найдены
	if len(metadata.Streams) == 0 || metadata.Streams[0].Width == 0 || metadata.Streams[0].Height == 0 {
		log.Fatalf("get video resolution error")
	}

	return metadata.Streams[0].Width, metadata.Streams[0].Height, nil
}

// calculateScaleFilter рассчитывает фильтр масштабирования
func calculateScaleFilter(width, height int) string {
	var targetWidth, targetHeight int

	if width <= targetWidth && height <= targetHeight {
		return fmt.Sprintf("%d:%d", width, height)
	}
	if width > height { // Горизонтальное видео
		log.Printf("Video is Horizontal")
		targetWidth = VIDEO_WIDTH
		targetHeight = VIDEO_HEIGHT
		log.Printf("Target resolution: %d:%d", targetWidth, targetHeight)
		diff_width := width - targetWidth // 1 (width_initial-width_target)
		log.Printf("difference in initial width and target: %d", diff_width)
		var proportion float64 = float64(diff_width) / float64(width) // 2 ([1] / width)
		log.Printf("how much we need to scale video to reach target resolution: %f", proportion)
		diff_height := proportion * float64(height) // 3 (height_initial * [2])
		log.Printf("how much we need to subtract from initial height to save proportion: %f", diff_height)
		newHeight := ensureEven(height - int(diff_height)) // 4 (height_initial - [3])
		log.Printf("subtract proportioned height from initial: %d", newHeight)

		log.Printf("%d:%d", targetWidth, newHeight)
		return fmt.Sprintf("%d:%d", targetWidth, newHeight)
	} else { // Вертикальное видео
		log.Printf("Video is Vertical")
		targetWidth = VIDEO_HEIGHT
		targetHeight = VIDEO_WIDTH
		log.Printf("Target resolution: %d:%d", targetWidth, targetHeight)
		diff_height := height - targetHeight // 1 (height_initial-height_target)
		log.Printf("difference in initial height and target: %d", diff_height)
		var proportion_coefficient float64 = float64(diff_height) / float64(height) // 2 ([1] / height)
		log.Printf("how much we need to scale video to reach target resolution: %f", proportion_coefficient)
		diff_width := float64(width) * proportion_coefficient // 3 (width_initial * [2])
		log.Printf("how much we need to subtract from initial width to save proportion: %f", diff_width)
		newWidth := ensureEven(width - int(diff_width)) // 4 (width_initial - [3])
		log.Printf("subtract proportioned width from initial: %d", newWidth)
		log.Printf("%d:%d", targetWidth, newWidth)

		return fmt.Sprintf("%d:%d", newWidth, targetHeight)
	}
}

// ensureEven гарантирует, что число чётное
func ensureEven(value int) int {
	if value%2 != 0 {
		return value + 1
	}
	return value
}