draft grpc

This commit is contained in:
2026-03-13 02:17:18 +00:00
parent ea2edbb9e0
commit 91e5e3542b
116 changed files with 44505 additions and 558 deletions

View File

@@ -1,16 +1,22 @@
//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/internal/database/query"
"stream.api/pkg/logger"
"stream.api/pkg/response"
"stream.api/pkg/storage"
@@ -19,13 +25,22 @@ import (
type Handler struct {
logger logger.Logger
cfg *config.Config
db *gorm.DB
storage storage.Provider
}
func NewHandler(l logger.Logger, cfg *config.Config, s storage.Provider) VideoHandler {
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,
}
}
@@ -62,7 +77,7 @@ func (h *Handler) GetUploadURL(c *gin.Context) {
response.Success(c, gin.H{
"upload_url": url,
"key": key,
"file_id": fileID, // Temporary ID, actual video record ID might differ or be same
"file_id": fileID,
})
}
@@ -85,38 +100,83 @@ func (h *Handler) CreateVideo(c *gin.Context) {
}
userID := c.GetString("userID")
status := "PUBLIC"
storageType := "S3"
video := &model.Video{
ID: uuid.New().String(),
UserID: userID,
Name: req.Title,
Title: req.Title,
Description: &req.Description,
URL: req.URL,
Size: req.Size,
Duration: req.Duration,
Format: req.Format,
Status: &status,
StorageType: &storageType,
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
q := query.Q
err := q.Transaction(func(tx *query.Query) error {
if err := tx.Video.WithContext(c.Request.Context()).Create(video); err != nil {
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
}
// Atomic update: StorageUsed = StorageUsed + video.Size
// We use UpdateSimple with Add to ensure atomicity at database level: UPDATE users SET storage_used = storage_used + ?
if _, err := tx.User.WithContext(c.Request.Context()).
Where(tx.User.ID.Eq(userID)).
UpdateSimple(tx.User.StorageUsed.Add(video.Size)); err != nil {
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
})
@@ -140,17 +200,46 @@ func (h *Handler) CreateVideo(c *gin.Context) {
// @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
v := query.Video
videos, count, err := v.WithContext(c.Request.Context()).
Where(v.Status.Eq("PUBLIC")).
Order(v.CreatedAt.Desc()).
FindByPage(offset, limit)
search := strings.TrimSpace(c.Query("search"))
status := strings.TrimSpace(c.Query("status"))
if err != nil {
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
@@ -158,7 +247,7 @@ func (h *Handler) ListVideos(c *gin.Context) {
response.Success(c, gin.H{
"videos": videos,
"total": count,
"total": total,
"page": page,
"limit": limit,
})
@@ -174,19 +263,321 @@ func (h *Handler) ListVideos(c *gin.Context) {
// @Router /videos/{id} [get]
// @Security BearerAuth
func (h *Handler) GetVideo(c *gin.Context) {
id := c.Param("id")
v := query.Video
// Atomically increment views: UPDATE videos SET views = views + 1 WHERE id = ?
// We intentionally ignore errors here (like record not found) because the subsequent fetch will handle 404s,
// and we don't want to fail the read if writing the view count fails for some transient reason.
v.WithContext(c.Request.Context()).Where(v.ID.Eq(id)).UpdateSimple(v.Views.Add(1))
video, err := v.WithContext(c.Request.Context()).Where(v.ID.Eq(id)).First()
if err != nil {
response.Error(c, http.StatusNotFound, "Video not found")
userID := c.GetString("userID")
if userID == "" {
response.Error(c, http.StatusUnauthorized, "Unauthorized")
return
}
response.Success(c, gin.H{"video": video})
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
}