584 lines
16 KiB
Go
584 lines
16 KiB
Go
//go:build ignore
|
|
// +build ignore
|
|
|
|
package video
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
"stream.api/internal/config"
|
|
"stream.api/internal/database/model"
|
|
"stream.api/pkg/logger"
|
|
"stream.api/pkg/response"
|
|
"stream.api/pkg/storage"
|
|
)
|
|
|
|
type Handler struct {
|
|
logger logger.Logger
|
|
cfg *config.Config
|
|
db *gorm.DB
|
|
storage storage.Provider
|
|
}
|
|
|
|
type videoError struct {
|
|
Code int
|
|
Message string
|
|
}
|
|
|
|
func (e *videoError) Error() string { return e.Message }
|
|
|
|
func NewHandler(l logger.Logger, cfg *config.Config, db *gorm.DB, s storage.Provider) VideoHandler {
|
|
return &Handler{
|
|
logger: l,
|
|
cfg: cfg,
|
|
db: db,
|
|
storage: s,
|
|
}
|
|
}
|
|
|
|
// @Summary Get Upload URL
|
|
// @Description Generate presigned URL for video upload
|
|
// @Tags video
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body UploadURLRequest true "File Info"
|
|
// @Success 200 {object} response.Response
|
|
// @Failure 400 {object} response.Response
|
|
// @Failure 500 {object} response.Response
|
|
// @Router /videos/upload-url [post]
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetUploadURL(c *gin.Context) {
|
|
var req UploadURLRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
userID := c.GetString("userID")
|
|
fileID := uuid.New().String()
|
|
key := fmt.Sprintf("videos/%s/%s-%s", userID, fileID, req.Filename)
|
|
|
|
url, err := h.storage.GeneratePresignedURL(key, 15*time.Minute)
|
|
if err != nil {
|
|
h.logger.Error("Failed to generate presigned URL", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Storage error")
|
|
return
|
|
}
|
|
|
|
response.Success(c, gin.H{
|
|
"upload_url": url,
|
|
"key": key,
|
|
"file_id": fileID,
|
|
})
|
|
}
|
|
|
|
// @Summary Create Video
|
|
// @Description Create video record after upload
|
|
// @Tags video
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body CreateVideoRequest true "Video Info"
|
|
// @Success 201 {object} response.Response{data=model.Video}
|
|
// @Failure 400 {object} response.Response
|
|
// @Failure 500 {object} response.Response
|
|
// @Router /videos [post]
|
|
// @Security BearerAuth
|
|
func (h *Handler) CreateVideo(c *gin.Context) {
|
|
var req CreateVideoRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
userID := c.GetString("userID")
|
|
if userID == "" {
|
|
response.Error(c, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
title := strings.TrimSpace(req.Title)
|
|
if title == "" {
|
|
response.Error(c, http.StatusBadRequest, "Title is required")
|
|
return
|
|
}
|
|
|
|
videoURL := strings.TrimSpace(req.URL)
|
|
if videoURL == "" {
|
|
response.Error(c, http.StatusBadRequest, "URL is required")
|
|
return
|
|
}
|
|
|
|
status := "ready"
|
|
processingStatus := "READY"
|
|
storageType := detectStorageType(videoURL)
|
|
description := strings.TrimSpace(req.Description)
|
|
format := strings.TrimSpace(req.Format)
|
|
|
|
video := &model.Video{
|
|
ID: uuid.New().String(),
|
|
UserID: userID,
|
|
Name: title,
|
|
Title: title,
|
|
Description: stringPointer(description),
|
|
URL: videoURL,
|
|
Size: req.Size,
|
|
Duration: req.Duration,
|
|
Format: format,
|
|
Status: &status,
|
|
ProcessingStatus: &processingStatus,
|
|
StorageType: &storageType,
|
|
}
|
|
|
|
err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
|
var defaultTemplate model.AdTemplate
|
|
hasDefaultTemplate := false
|
|
|
|
if err := tx.Where("user_id = ? AND is_default = ? AND is_active = ?", userID, true, true).
|
|
Order("updated_at DESC").
|
|
First(&defaultTemplate).Error; err != nil {
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
} else {
|
|
hasDefaultTemplate = true
|
|
}
|
|
|
|
if err := tx.Create(video).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Model(&model.User{}).
|
|
Where("id = ?", userID).
|
|
UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
if hasDefaultTemplate {
|
|
videoAdConfig := &model.VideoAdConfig{
|
|
VideoID: video.ID,
|
|
UserID: userID,
|
|
AdTemplateID: defaultTemplate.ID,
|
|
VastTagURL: defaultTemplate.VastTagURL,
|
|
AdFormat: defaultTemplate.AdFormat,
|
|
Duration: defaultTemplate.Duration,
|
|
}
|
|
|
|
if err := tx.Create(videoAdConfig).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
h.logger.Error("Failed to create video record", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to create video")
|
|
return
|
|
}
|
|
|
|
response.Created(c, gin.H{"video": video})
|
|
}
|
|
|
|
// @Summary List Videos
|
|
// @Description Get paginated videos
|
|
// @Tags video
|
|
// @Produce json
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param limit query int false "Page size" default(10)
|
|
// @Success 200 {object} response.Response
|
|
// @Failure 500 {object} response.Response
|
|
// @Router /videos [get]
|
|
// @Security BearerAuth
|
|
func (h *Handler) ListVideos(c *gin.Context) {
|
|
userID := c.GetString("userID")
|
|
if userID == "" {
|
|
response.Error(c, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
offset := (page - 1) * limit
|
|
|
|
search := strings.TrimSpace(c.Query("search"))
|
|
status := strings.TrimSpace(c.Query("status"))
|
|
|
|
db := h.db.WithContext(c.Request.Context()).Model(&model.Video{}).Where("user_id = ?", userID)
|
|
if search != "" {
|
|
like := "%" + search + "%"
|
|
db = db.Where("title ILIKE ? OR description ILIKE ?", like, like)
|
|
}
|
|
if status != "" && !strings.EqualFold(status, "all") {
|
|
db = db.Where("status = ?", normalizeVideoStatus(status))
|
|
}
|
|
|
|
var total int64
|
|
if err := db.Count(&total).Error; err != nil {
|
|
h.logger.Error("Failed to count videos", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to fetch videos")
|
|
return
|
|
}
|
|
|
|
var videos []*model.Video
|
|
if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&videos).Error; err != nil {
|
|
h.logger.Error("Failed to fetch videos", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to fetch videos")
|
|
return
|
|
}
|
|
|
|
response.Success(c, gin.H{
|
|
"videos": videos,
|
|
"total": total,
|
|
"page": page,
|
|
"limit": limit,
|
|
})
|
|
}
|
|
|
|
// @Summary Get Video
|
|
// @Description Get video details by ID
|
|
// @Tags video
|
|
// @Produce json
|
|
// @Param id path string true "Video ID"
|
|
// @Success 200 {object} response.Response{data=model.Video}
|
|
// @Failure 404 {object} response.Response
|
|
// @Router /videos/{id} [get]
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetVideo(c *gin.Context) {
|
|
userID := c.GetString("userID")
|
|
if userID == "" {
|
|
response.Error(c, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
id := c.Param("id")
|
|
|
|
h.db.WithContext(c.Request.Context()).Model(&model.Video{}).
|
|
Where("id = ? AND user_id = ?", id, userID).
|
|
UpdateColumn("views", gorm.Expr("views + ?", 1))
|
|
|
|
var video model.Video
|
|
if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND user_id = ?", id, userID).First(&video).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
response.Error(c, http.StatusNotFound, "Video not found")
|
|
return
|
|
}
|
|
h.logger.Error("Failed to fetch video", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to fetch video")
|
|
return
|
|
}
|
|
|
|
result := gin.H{"video": &video}
|
|
|
|
var adConfig model.VideoAdConfig
|
|
if err := h.db.WithContext(c.Request.Context()).
|
|
Where("video_id = ? AND user_id = ?", id, userID).
|
|
First(&adConfig).Error; err == nil {
|
|
adPayload := VideoAdConfigPayload{
|
|
AdTemplateID: adConfig.AdTemplateID,
|
|
VASTTagURL: adConfig.VastTagURL,
|
|
AdFormat: model.StringValue(adConfig.AdFormat),
|
|
Duration: int64PtrToIntPtr(adConfig.Duration),
|
|
}
|
|
|
|
var template model.AdTemplate
|
|
if err := h.db.WithContext(c.Request.Context()).
|
|
Where("id = ? AND user_id = ?", adConfig.AdTemplateID, userID).
|
|
First(&template).Error; err == nil {
|
|
adPayload.TemplateName = template.Name
|
|
}
|
|
|
|
result["ad_config"] = adPayload
|
|
}
|
|
|
|
response.Success(c, result)
|
|
}
|
|
|
|
// @Summary Update Video
|
|
// @Description Update title and description for a video owned by the current user
|
|
// @Tags video
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Video ID"
|
|
// @Param request body UpdateVideoRequest true "Video payload"
|
|
// @Success 200 {object} response.Response
|
|
// @Failure 400 {object} response.Response
|
|
// @Failure 401 {object} response.Response
|
|
// @Failure 404 {object} response.Response
|
|
// @Failure 500 {object} response.Response
|
|
// @Router /videos/{id} [put]
|
|
// @Security BearerAuth
|
|
func (h *Handler) UpdateVideo(c *gin.Context) {
|
|
userID := c.GetString("userID")
|
|
if userID == "" {
|
|
response.Error(c, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
id := c.Param("id")
|
|
var req UpdateVideoRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.Error(c, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
title := strings.TrimSpace(req.Title)
|
|
if title == "" {
|
|
response.Error(c, http.StatusBadRequest, "Title is required")
|
|
return
|
|
}
|
|
description := strings.TrimSpace(req.Description)
|
|
ctx := c.Request.Context()
|
|
|
|
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
result := tx.Model(&model.Video{}).
|
|
Where("id = ? AND user_id = ?", id, userID).
|
|
Updates(map[string]interface{}{
|
|
"name": title,
|
|
"title": title,
|
|
"description": stringPointer(description),
|
|
})
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
|
|
if req.AdTemplateID != nil {
|
|
templateID := strings.TrimSpace(*req.AdTemplateID)
|
|
|
|
if templateID == "" {
|
|
tx.Where("video_id = ? AND user_id = ?", id, userID).Delete(&model.VideoAdConfig{})
|
|
} else {
|
|
var template model.AdTemplate
|
|
if err := tx.Where("id = ? AND user_id = ?", templateID, userID).
|
|
First(&template).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return &videoError{Code: http.StatusBadRequest, Message: "Ad template not found"}
|
|
}
|
|
return err
|
|
}
|
|
|
|
var existing model.VideoAdConfig
|
|
if err := tx.Where("video_id = ? AND user_id = ?", id, userID).
|
|
First(&existing).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
newConfig := &model.VideoAdConfig{
|
|
VideoID: id,
|
|
UserID: userID,
|
|
AdTemplateID: template.ID,
|
|
VastTagURL: template.VastTagURL,
|
|
AdFormat: template.AdFormat,
|
|
Duration: template.Duration,
|
|
}
|
|
return tx.Create(newConfig).Error
|
|
}
|
|
return err
|
|
}
|
|
|
|
existing.AdTemplateID = template.ID
|
|
existing.VastTagURL = template.VastTagURL
|
|
existing.AdFormat = template.AdFormat
|
|
existing.Duration = template.Duration
|
|
return tx.Save(&existing).Error
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
response.Error(c, http.StatusNotFound, "Video not found")
|
|
return
|
|
}
|
|
var ve *videoError
|
|
if errors.As(err, &ve) {
|
|
response.Error(c, ve.Code, ve.Message)
|
|
return
|
|
}
|
|
h.logger.Error("Failed to update video", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to update video")
|
|
return
|
|
}
|
|
|
|
var video model.Video
|
|
if err := h.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).First(&video).Error; err != nil {
|
|
h.logger.Error("Failed to reload video", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to update video")
|
|
return
|
|
}
|
|
|
|
resp := gin.H{"video": &video}
|
|
|
|
var adConfig model.VideoAdConfig
|
|
if err := h.db.WithContext(ctx).
|
|
Where("video_id = ? AND user_id = ?", id, userID).
|
|
First(&adConfig).Error; err == nil {
|
|
adPayload := VideoAdConfigPayload{
|
|
AdTemplateID: adConfig.AdTemplateID,
|
|
VASTTagURL: adConfig.VastTagURL,
|
|
AdFormat: model.StringValue(adConfig.AdFormat),
|
|
Duration: int64PtrToIntPtr(adConfig.Duration),
|
|
}
|
|
|
|
var template model.AdTemplate
|
|
if err := h.db.WithContext(ctx).
|
|
Where("id = ? AND user_id = ?", adConfig.AdTemplateID, userID).
|
|
First(&template).Error; err == nil {
|
|
adPayload.TemplateName = template.Name
|
|
}
|
|
|
|
resp["ad_config"] = adPayload
|
|
}
|
|
|
|
response.Success(c, resp)
|
|
}
|
|
|
|
// @Summary Delete Video
|
|
// @Description Delete a video owned by the current user
|
|
// @Tags video
|
|
// @Produce json
|
|
// @Param id path string true "Video ID"
|
|
// @Success 200 {object} response.Response
|
|
// @Failure 401 {object} response.Response
|
|
// @Failure 404 {object} response.Response
|
|
// @Failure 500 {object} response.Response
|
|
// @Router /videos/{id} [delete]
|
|
// @Security BearerAuth
|
|
func (h *Handler) DeleteVideo(c *gin.Context) {
|
|
userID := c.GetString("userID")
|
|
if userID == "" {
|
|
response.Error(c, http.StatusUnauthorized, "Unauthorized")
|
|
return
|
|
}
|
|
|
|
id := c.Param("id")
|
|
var video model.Video
|
|
if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND user_id = ?", id, userID).First(&video).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
response.Error(c, http.StatusNotFound, "Video not found")
|
|
return
|
|
}
|
|
h.logger.Error("Failed to load video for deletion", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to delete video")
|
|
return
|
|
}
|
|
|
|
if h.storage != nil && shouldDeleteStoredObject(video.URL) {
|
|
if err := h.storage.Delete(video.URL); err != nil {
|
|
parsedKey := extractObjectKey(video.URL)
|
|
if parsedKey != "" && parsedKey != video.URL {
|
|
if deleteErr := h.storage.Delete(parsedKey); deleteErr != nil {
|
|
h.logger.Error("Failed to delete video object", "error", deleteErr, "video_id", video.ID)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to delete video")
|
|
return
|
|
}
|
|
} else {
|
|
h.logger.Error("Failed to delete video object", "error", err, "video_id", video.ID)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to delete video")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Where("video_id = ? AND user_id = ?", video.ID, userID).Delete(&model.VideoAdConfig{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Where("id = ? AND user_id = ?", video.ID, userID).Delete(&model.Video{}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&model.User{}).
|
|
Where("id = ?", userID).
|
|
UpdateColumn("storage_used", gorm.Expr("storage_used - ?", video.Size)).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
h.logger.Error("Failed to delete video record", "error", err)
|
|
response.Error(c, http.StatusInternalServerError, "Failed to delete video")
|
|
return
|
|
}
|
|
|
|
response.Success(c, gin.H{"message": "Video deleted successfully"})
|
|
}
|
|
|
|
func normalizeVideoStatus(value string) string {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "processing", "pending":
|
|
return "processing"
|
|
case "failed", "error":
|
|
return "failed"
|
|
default:
|
|
return "ready"
|
|
}
|
|
}
|
|
|
|
func detectStorageType(rawURL string) string {
|
|
if shouldDeleteStoredObject(rawURL) {
|
|
return "S3"
|
|
}
|
|
return "WORKER"
|
|
}
|
|
|
|
func shouldDeleteStoredObject(rawURL string) bool {
|
|
trimmed := strings.TrimSpace(rawURL)
|
|
if trimmed == "" {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil {
|
|
return !strings.HasPrefix(trimmed, "/")
|
|
}
|
|
return parsed.Scheme == "" && parsed.Host == "" && !strings.HasPrefix(trimmed, "/")
|
|
}
|
|
|
|
func extractObjectKey(rawURL string) string {
|
|
trimmed := strings.TrimSpace(rawURL)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil {
|
|
return trimmed
|
|
}
|
|
if parsed.Scheme == "" && parsed.Host == "" {
|
|
return strings.TrimPrefix(parsed.Path, "/")
|
|
}
|
|
return strings.TrimPrefix(parsed.Path, "/")
|
|
}
|
|
|
|
func stringPointer(value string) *string {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|
|
|
|
func int64PtrToIntPtr(value *int64) *int {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
converted := int(*value)
|
|
return &converted
|
|
}
|