tailly_clips/internal/service/clip_service.go
2025-09-02 23:17:24 +03:00

188 lines
5.9 KiB
Go

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. Параллельно обрабатываем видео и генерируем превью
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)
}
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
}