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" log.Printf("%s: starting clip creation for user %d", op, req.UserID) // 1. Параллельно обрабатываем видео и генерируем превью trimmedVideoChan := make(chan []byte, 1) trimmedErrChan := make(chan error, 1) thumbnailChan := make(chan []byte, 1) thumbnailErrChan := make(chan error, 1) // Горутина для обрезки видео go func() { trimmedVideo, err := s.videoProcessor.TrimVideo(req.VideoData, 30) if err != nil { trimmedErrChan <- fmt.Errorf("%s: failed to trim video: %w", op, err) return } trimmedVideoChan <- trimmedVideo }() // Горутина для генерации превью go func() { thumbnailData, err := s.videoProcessor.GenerateThumbnail(req.VideoData) if err != nil { thumbnailErrChan <- fmt.Errorf("%s: failed to generate thumbnail: %w", op, err) return } thumbnailChan <- thumbnailData }() // 2. Ждем результаты обработки var trimmedVideo []byte var thumbnailData []byte select { case trimmedVideo = <-trimmedVideoChan: case err := <-trimmedErrChan: return nil, err case <-ctx.Done(): return nil, fmt.Errorf("%s: operation cancelled", op) } select { case thumbnailData = <-thumbnailChan: case err := <-thumbnailErrChan: return nil, err case <-ctx.Done(): return nil, fmt.Errorf("%s: operation cancelled", op) } // 3. Загружаем обрезанное видео и превью в S3 trimmedVideoURL, err := s.storage.UploadVideo(ctx, trimmedVideo, req.FileName, req.UserID) if err != nil { return nil, fmt.Errorf("%s: failed to upload trimmed video: %w", op, err) } thumbnailURL, err := s.storage.UploadThumbnail(ctx, thumbnailData, req.UserID) if err != nil { s.storage.DeleteVideo(ctx, trimmedVideoURL) return nil, fmt.Errorf("%s: failed to upload thumbnail: %w", op, err) } // 4. Модерируем обрезанное видео videoAllowed, err := s.modClient.CheckVideoUrl(ctx, trimmedVideoURL) if err != nil { s.storage.DeleteVideo(ctx, trimmedVideoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, fmt.Errorf("%s: video moderation failed: %w", op, err) } if !videoAllowed { s.storage.DeleteVideo(ctx, trimmedVideoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, fmt.Errorf("%s: video rejected by moderation service", op) } // 5. Создаем клип в БД clip := &domain.Clip{ Title: req.Title, VideoURL: trimmedVideoURL, ThumbnailURL: thumbnailURL, 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, trimmedVideoURL) s.storage.DeleteThumbnail(ctx, thumbnailURL) return nil, fmt.Errorf("failed to create clip: %w", err) } log.Printf("%s: clip created successfully, ID: %d", op, clip.ID) // Проверяем что все поля заполнены if clip.VideoURL == "" { log.Printf("%s: WARNING - VideoURL is empty", op) } if clip.ThumbnailURL == "" { log.Printf("%s: WARNING - ThumbnailURL is empty", op) } if clip.AuthorID == 0 { log.Printf("%s: WARNING - AuthorID is 0", op) } 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 }