package service import ( "context" "fmt" "log" "tailly_clips/internal/domain" "tailly_clips/internal/ffmpeg" "tailly_clips/internal/moderation" "tailly_clips/internal/repository" "tailly_clips/internal/storage" "time" ) type ModerationClient interface { CheckVideoUrl(ctx context.Context, videoUrl string) (bool, error) Close() } type ClipService interface { CreateClip(ctx context.Context, req domain.CreateClipRequest) (*domain.Clip, error) GetClip(ctx context.Context, clipID int) (*domain.Clip, error) GetUserClips(ctx context.Context, userID, limit, offset int) ([]*domain.Clip, int, error) GetClips(ctx context.Context, limit, offset int) ([]*domain.Clip, int, error) DeleteClip(ctx context.Context, clipID, userID int) error } type clipService struct { repo repository.ClipRepository storage storage.Storage videoProcessor ffmpeg.VideoProcessor modClient *moderation.ModerationClient } func NewClipService(repo repository.ClipRepository, storage storage.Storage, processor ffmpeg.VideoProcessor, modClient *moderation.ModerationClient) ClipService { return &clipService{ repo: repo, storage: storage, videoProcessor: processor, modClient: modClient, } } func (s *clipService) CreateClip(ctx context.Context, req domain.CreateClipRequest) (*domain.Clip, error) { const op = "service/clipService.CreateClip" // 1. Обрезаем видео до 30 секунд в памяти trimmedVideoData, err := s.videoProcessor.TrimVideo(req.VideoData, 30) if err != nil { return nil, fmt.Errorf("%s: failed to trim video: %w", op, err) } // 2. Параллельно выполняем операции videoURLChan := make(chan string, 1) videoErrChan := make(chan error, 1) thumbnailChan := make(chan string, 1) thumbnailErrChan := make(chan error, 1) durationChan := make(chan int, 1) durationErrChan := make(chan error, 1) // Горутина для загрузки видео в S3 go func() { videoURL, err := s.storage.UploadVideo(ctx, trimmedVideoData, req.FileName, req.UserID) if err != nil { videoErrChan <- fmt.Errorf("%s: failed to upload video: %w", op, err) return } videoURLChan <- videoURL }() // Горутина для генерации превью go func() { thumbnailData, err := s.videoProcessor.GenerateThumbnail(trimmedVideoData) if err != nil { thumbnailErrChan <- fmt.Errorf("%s: failed to generate thumbnail: %w", op, err) return } thumbnailURL, err := s.storage.UploadThumbnail(ctx, thumbnailData, req.UserID) if err != nil { thumbnailErrChan <- fmt.Errorf("%s: failed to upload thumbnail: %w", op, err) return } thumbnailChan <- thumbnailURL }() // Горутина для получения длительности go func() { duration, err := s.videoProcessor.GetDuration(trimmedVideoData) if err != nil { durationErrChan <- fmt.Errorf("%s: failed to get duration: %w", op, err) return } durationChan <- duration }() // 3. Ждем результаты всех операций var videoURL, thumbnailURL string var duration int // Ждем загрузки видео select { case videoURL = <-videoURLChan: case err := <-videoErrChan: return nil, err case <-ctx.Done(): return nil, fmt.Errorf("%s: operation cancelled", op) } // Ждем генерации превью select { case thumbnailURL = <-thumbnailChan: case err := <-thumbnailErrChan: s.storage.DeleteVideo(ctx, videoURL) return nil, err case <-ctx.Done(): s.storage.DeleteVideo(ctx, videoURL) return nil, fmt.Errorf("%s: operation cancelled", op) } // Ждем получения длительности select { case duration = <-durationChan: case err := <-durationErrChan: s.storage.DeleteVideo(ctx, videoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, err case <-ctx.Done(): s.storage.DeleteVideo(ctx, videoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, fmt.Errorf("%s: operation cancelled", op) } // 4. Модерируем обрезанное видео по URL videoAllowed, err := s.modClient.CheckVideoUrl(ctx, videoURL) if err != nil { s.storage.DeleteVideo(ctx, videoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, fmt.Errorf("%s: video moderation failed: %w", op, err) } if !videoAllowed { s.storage.DeleteVideo(ctx, videoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, fmt.Errorf("%s: video rejected by moderation service", op) } // 5. Создаем клип в БД clip := &domain.Clip{ Title: req.Title, VideoURL: videoURL, ThumbnailURL: thumbnailURL, Duration: duration, AuthorID: req.UserID, LikesCount: 0, CommentsCount: 0, CreatedAt: time.Now(), UpdatedAt: time.Now(), } if err := s.repo.Create(ctx, clip); err != nil { s.storage.DeleteVideo(ctx, videoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, fmt.Errorf("failed to create clip: %w", err) } return clip, nil } func (s *clipService) GetClip(ctx context.Context, clipID int) (*domain.Clip, error) { clip, err := s.repo.GetByID(ctx, clipID) if err != nil { return nil, fmt.Errorf("failed to get clip: %w", err) } return clip, nil } func (s *clipService) GetUserClips(ctx context.Context, userID, limit, offset int) ([]*domain.Clip, int, error) { clips, totalCount, err := s.repo.GetByAuthorID(ctx, userID, limit, offset) if err != nil { return nil, 0, fmt.Errorf("failed to get user clips: %w", err) } return clips, totalCount, nil } func (s *clipService) GetClips(ctx context.Context, limit, offset int) ([]*domain.Clip, int, error) { clips, totalCount, err := s.repo.GetAll(ctx, limit, offset) if err != nil { return nil, 0, fmt.Errorf("failed to get clips: %w", err) } return clips, totalCount, nil } func (s *clipService) DeleteClip(ctx context.Context, clipID, userID int) error { // Получаем клип для проверки прав и получения URL clip, err := s.repo.GetByID(ctx, clipID) if err != nil { return fmt.Errorf("failed to get clip: %w", err) } // Проверяем права доступа if clip.AuthorID != userID { return fmt.Errorf("user %d is not the author of clip %d", userID, clipID) } // Удаляем файлы из S3 fileURLs := []string{clip.VideoURL, clip.ThumbnailURL} if err := s.storage.BulkDelete(ctx, fileURLs); err != nil { return fmt.Errorf("failed to delete files from S3: %w", err) } // Удаляем клип из БД if err := s.repo.Delete(ctx, clipID); err != nil { // Логируем ошибку но не возвращаем её, так как файлы уже удалены log.Printf("WARNING: Failed to delete clip %d from database after S3 deletion: %v", clipID, err) } return nil }