406 lines
14 KiB
Go
406 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"gorm.io/gorm"
|
|
appv1 "stream.api/internal/api/proto/app/v1"
|
|
"stream.api/internal/database/model"
|
|
)
|
|
|
|
func (s *videosAppService) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlRequest) (*appv1.GetUploadUrlResponse, error) {
|
|
result, err := s.authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s.storageProvider == nil {
|
|
return nil, status.Error(codes.FailedPrecondition, "Storage provider is not configured")
|
|
}
|
|
|
|
filename := strings.TrimSpace(req.GetFilename())
|
|
if filename == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "Filename is required")
|
|
}
|
|
|
|
fileID := uuid.New().String()
|
|
key := fmt.Sprintf("videos/%s/%s-%s", result.UserID, fileID, filename)
|
|
uploadURL, err := s.storageProvider.GeneratePresignedURL(key, 15*time.Minute)
|
|
if err != nil {
|
|
s.logger.Error("Failed to generate upload URL", "error", err)
|
|
return nil, status.Error(codes.Internal, "Storage error")
|
|
}
|
|
|
|
return &appv1.GetUploadUrlResponse{UploadUrl: uploadURL, Key: key, FileId: fileID}, nil
|
|
}
|
|
func (s *videosAppService) CreateVideo(ctx context.Context, req *appv1.CreateVideoRequest) (*appv1.CreateVideoResponse, error) {
|
|
result, err := s.authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s.videoWorkflowService == nil {
|
|
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
}
|
|
|
|
title := strings.TrimSpace(req.GetTitle())
|
|
if title == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "Title is required")
|
|
}
|
|
videoURL := strings.TrimSpace(req.GetUrl())
|
|
if videoURL == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "URL is required")
|
|
}
|
|
description := strings.TrimSpace(req.GetDescription())
|
|
|
|
created, err := s.videoWorkflowService.CreateVideo(ctx, CreateVideoInput{
|
|
UserID: result.UserID,
|
|
Title: title,
|
|
Description: &description,
|
|
URL: videoURL,
|
|
Size: req.GetSize(),
|
|
Duration: req.GetDuration(),
|
|
Format: strings.TrimSpace(req.GetFormat()),
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("Failed to create video", "error", err)
|
|
switch {
|
|
case errors.Is(err, ErrJobServiceUnavailable):
|
|
return nil, status.Error(codes.Unavailable, "Job service is unavailable")
|
|
default:
|
|
return nil, status.Error(codes.Internal, "Failed to create video")
|
|
}
|
|
}
|
|
|
|
return &appv1.CreateVideoResponse{Video: toProtoVideo(created.Video, created.Job.ID)}, nil
|
|
}
|
|
func (s *videosAppService) ListVideos(ctx context.Context, req *appv1.ListVideosRequest) (*appv1.ListVideosResponse, error) {
|
|
result, err := s.authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
page := req.GetPage()
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
limit := req.GetLimit()
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
offset := int((page - 1) * limit)
|
|
if s.videoRepository == nil {
|
|
return nil, status.Error(codes.Internal, "Video repository is unavailable")
|
|
}
|
|
|
|
videos, total, err := s.videoRepository.ListByUser(ctx, result.UserID, req.GetSearch(), normalizeVideoStatusFilter(req.GetStatus()), offset, int(limit))
|
|
if err != nil {
|
|
s.logger.Error("Failed to list videos", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch videos")
|
|
}
|
|
|
|
items := make([]*appv1.Video, 0, len(videos))
|
|
for i := range videos {
|
|
payload, err := s.buildVideo(ctx, &videos[i])
|
|
if err != nil {
|
|
s.logger.Error("Failed to build video payload", "error", err, "video_id", videos[i].ID)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch videos")
|
|
}
|
|
items = append(items, payload)
|
|
}
|
|
|
|
return &appv1.ListVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil
|
|
}
|
|
func (s *videosAppService) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) (*appv1.GetVideoResponse, error) {
|
|
result, err := s.authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := strings.TrimSpace(req.GetId())
|
|
if id == "" {
|
|
return nil, status.Error(codes.NotFound, "Video not found")
|
|
}
|
|
|
|
if s.videoRepository == nil {
|
|
return nil, status.Error(codes.Internal, "Video repository is unavailable")
|
|
}
|
|
|
|
_ = s.videoRepository.IncrementViews(ctx, id, result.UserID)
|
|
|
|
video, err := s.videoRepository.GetByIDAndUser(ctx, id, result.UserID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, status.Error(codes.NotFound, "Video not found")
|
|
}
|
|
s.logger.Error("Failed to fetch video", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch video")
|
|
}
|
|
|
|
payload, err := s.buildVideo(ctx, video)
|
|
if err != nil {
|
|
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch video")
|
|
}
|
|
return &appv1.GetVideoResponse{Video: payload}, nil
|
|
}
|
|
|
|
func (s *videosAppService) GetVideoMetadata(ctx context.Context, req *appv1.GetVideoMetadataRequest) (*appv1.GetVideoMetadataResponse, error) {
|
|
if _, err := s.authenticator.RequireTrustedMetadata(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
videoID := strings.TrimSpace(req.GetVideoId())
|
|
if videoID == "" {
|
|
return nil, status.Error(codes.NotFound, "Video not found")
|
|
}
|
|
if s.videoRepository == nil {
|
|
return nil, status.Error(codes.Internal, "Video repository is unavailable")
|
|
}
|
|
video, err := s.videoRepository.GetByID(ctx, videoID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, status.Error(codes.NotFound, "Video not found")
|
|
}
|
|
s.logger.Error("Failed to fetch video metadata source video", "error", err, "video_id", videoID)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch video metadata")
|
|
}
|
|
videoPayload, err := s.buildVideo(ctx, video)
|
|
if err != nil {
|
|
s.logger.Error("Failed to build video metadata video payload", "error", err, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch video metadata")
|
|
}
|
|
|
|
ownerID := strings.TrimSpace(video.UserID)
|
|
ownerUser, err := s.userRepository.GetByID(ctx, ownerID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to load video owner for metadata", "error", err, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch video metadata")
|
|
}
|
|
configOwnerID := ownerID
|
|
if ownerUser.PlanID == nil || strings.TrimSpace(*ownerUser.PlanID) == "" {
|
|
configOwnerID, err = s.resolveSystemConfigOwnerID(ctx)
|
|
if err != nil {
|
|
s.logger.Error("Failed to resolve system config owner", "error", err, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch video metadata")
|
|
}
|
|
}
|
|
|
|
domains, err := s.domainRepository.ListByUser(ctx, ownerID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to load video metadata domains", "error", err, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch video metadata")
|
|
}
|
|
protoDomains := make([]*appv1.Domain, 0, len(domains))
|
|
for i := range domains {
|
|
item := domains[i]
|
|
protoDomains = append(protoDomains, toProtoDomain(&item))
|
|
}
|
|
|
|
playerConfig, err := s.resolveDefaultPlayerConfig(ctx, configOwnerID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to load default player config for video metadata", "error", err, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch video metadata")
|
|
}
|
|
if playerConfig == nil {
|
|
return nil, status.Error(codes.FailedPrecondition, "Default player config is required")
|
|
}
|
|
popupAd, err := s.resolveActivePopupAd(ctx, configOwnerID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to load popup ad for video metadata", "error", err, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch video metadata")
|
|
}
|
|
if popupAd == nil {
|
|
return nil, status.Error(codes.FailedPrecondition, "Active popup ad is required")
|
|
}
|
|
adTemplate, err := s.resolveEffectiveAdTemplate(ctx, video, ownerID, configOwnerID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to load ad template for video metadata", "error", err, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to fetch video metadata")
|
|
}
|
|
if adTemplate == nil {
|
|
return nil, status.Error(codes.FailedPrecondition, "Ad template is required")
|
|
}
|
|
if len(protoDomains) == 0 {
|
|
return nil, status.Error(codes.FailedPrecondition, "At least one domain is required")
|
|
}
|
|
|
|
return &appv1.GetVideoMetadataResponse{
|
|
Video: videoPayload,
|
|
DefaultPlayerConfig: toProtoPlayerConfig(playerConfig),
|
|
AdTemplate: toProtoAdTemplate(adTemplate),
|
|
ActivePopupAd: toProtoPopupAd(popupAd),
|
|
Domains: protoDomains,
|
|
}, nil
|
|
}
|
|
func (s *videosAppService) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoRequest) (*appv1.UpdateVideoResponse, error) {
|
|
result, err := s.authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := strings.TrimSpace(req.GetId())
|
|
if id == "" {
|
|
return nil, status.Error(codes.NotFound, "Video not found")
|
|
}
|
|
|
|
updates := map[string]any{}
|
|
if title := strings.TrimSpace(req.GetTitle()); title != "" {
|
|
updates["name"] = title
|
|
updates["title"] = title
|
|
}
|
|
if req.Description != nil {
|
|
desc := strings.TrimSpace(req.GetDescription())
|
|
updates["description"] = nullableTrimmedString(&desc)
|
|
}
|
|
if urlValue := strings.TrimSpace(req.GetUrl()); urlValue != "" {
|
|
updates["url"] = urlValue
|
|
}
|
|
if req.Size > 0 {
|
|
updates["size"] = req.GetSize()
|
|
}
|
|
if req.Duration > 0 {
|
|
updates["duration"] = req.GetDuration()
|
|
}
|
|
if req.Format != nil {
|
|
updates["format"] = strings.TrimSpace(req.GetFormat())
|
|
}
|
|
if req.Status != nil {
|
|
updates["status"] = normalizeVideoStatusValue(req.GetStatus())
|
|
}
|
|
if len(updates) == 0 {
|
|
return nil, status.Error(codes.InvalidArgument, "No changes provided")
|
|
}
|
|
|
|
if s.videoRepository == nil {
|
|
return nil, status.Error(codes.Internal, "Video repository is unavailable")
|
|
}
|
|
|
|
rowsAffected, err := s.videoRepository.UpdateByIDAndUser(ctx, id, result.UserID, updates)
|
|
if err != nil {
|
|
s.logger.Error("Failed to update video", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to update video")
|
|
}
|
|
if rowsAffected == 0 {
|
|
return nil, status.Error(codes.NotFound, "Video not found")
|
|
}
|
|
|
|
video, err := s.videoRepository.GetByIDAndUser(ctx, id, result.UserID)
|
|
if err != nil {
|
|
s.logger.Error("Failed to reload video", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to update video")
|
|
}
|
|
|
|
payload, err := s.buildVideo(ctx, video)
|
|
if err != nil {
|
|
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to update video")
|
|
}
|
|
return &appv1.UpdateVideoResponse{Video: payload}, nil
|
|
}
|
|
func (s *videosAppService) resolveSystemConfigOwnerID(ctx context.Context) (string, error) {
|
|
users, _, err := s.userRepository.ListForAdmin(ctx, "", "ADMIN", 1, 0)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(users) == 0 {
|
|
return "", fmt.Errorf("system config owner not found")
|
|
}
|
|
return users[0].ID, nil
|
|
}
|
|
|
|
func (s *videosAppService) resolveDefaultPlayerConfig(ctx context.Context, userID string) (*model.PlayerConfig, error) {
|
|
items, err := s.playerConfigRepo.ListByUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &items[0], nil
|
|
}
|
|
|
|
func (s *videosAppService) resolveActivePopupAd(ctx context.Context, userID string) (*model.PopupAd, error) {
|
|
item, err := s.popupAdRepository.GetActiveByUser(ctx, userID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func (s *videosAppService) resolveEffectiveAdTemplate(ctx context.Context, video *model.Video, ownerID string, configOwnerID string) (*model.AdTemplate, error) {
|
|
if video != nil && video.AdID != nil && strings.TrimSpace(*video.AdID) != "" {
|
|
item, err := s.adTemplateRepository.GetByIDAndUser(ctx, strings.TrimSpace(*video.AdID), ownerID)
|
|
if err == nil {
|
|
return item, nil
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, err
|
|
}
|
|
}
|
|
items, err := s.adTemplateRepository.ListByUser(ctx, configOwnerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &items[0], nil
|
|
}
|
|
|
|
func (s *videosAppService) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoRequest) (*appv1.MessageResponse, error) {
|
|
result, err := s.authenticate(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := strings.TrimSpace(req.GetId())
|
|
if id == "" {
|
|
return nil, status.Error(codes.NotFound, "Video not found")
|
|
}
|
|
|
|
if s.videoRepository == nil {
|
|
return nil, status.Error(codes.Internal, "Video repository is unavailable")
|
|
}
|
|
|
|
video, err := s.videoRepository.GetByIDAndUser(ctx, id, result.UserID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, status.Error(codes.NotFound, "Video not found")
|
|
}
|
|
s.logger.Error("Failed to load video", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to delete video")
|
|
}
|
|
|
|
if s.storageProvider != nil && shouldDeleteStoredObject(video.URL) {
|
|
if err := s.storageProvider.Delete(video.URL); err != nil {
|
|
if parsedKey := extractObjectKey(video.URL); parsedKey != "" && parsedKey != video.URL {
|
|
if deleteErr := s.storageProvider.Delete(parsedKey); deleteErr != nil {
|
|
s.logger.Error("Failed to delete video object", "error", deleteErr, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to delete video")
|
|
}
|
|
} else {
|
|
s.logger.Error("Failed to delete video object", "error", err, "video_id", video.ID)
|
|
return nil, status.Error(codes.Internal, "Failed to delete video")
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := s.videoRepository.DeleteByIDAndUserWithStorageUpdate(ctx, video.ID, result.UserID, video.Size); err != nil {
|
|
s.logger.Error("Failed to delete video", "error", err)
|
|
return nil, status.Error(codes.Internal, "Failed to delete video")
|
|
}
|
|
|
|
return messageResponse("Video deleted successfully"), nil
|
|
}
|