v.0.0.1 Создан сервис клипов
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
admin 2025-09-02 11:58:10 +03:00
commit 57eba68496
31 changed files with 5777 additions and 0 deletions

21
.drone.yml Normal file
View File

@ -0,0 +1,21 @@
kind: pipeline
name: deploy
steps:
- name: deploy
image: appleboy/drone-ssh
settings:
host: 89.104.69.222
username: root
password:
from_secret: ssh_password
script:
- rm -fr /home/tailly_clips
- mkdir /home/tailly_clips
- cd /home/tailly_clips
- git clone https://admin:2bfa8b81e8787c9c0bb89e1a7bbd929b2d63aaf2@git.altomta.ru/admin/tailly_clips . || true
- git pull
- docker stop tailly_clips || true
- docker rm tailly_clips || true
- DOCKER_BUILDKIT=1 docker build -t tailly_clips .
- docker run -d --name tailly_clips --network tailly_net -p 50054:50054 tailly_clips

13
.env Normal file
View File

@ -0,0 +1,13 @@
DB_HOST=79.174.89.104
DB_PORT=15452
DB_USER=tailly_clips
DB_PASSWORD=3Xtk9l0k&DSV789XJ
DB_NAME=tailly_clips
S3_ENDPOINT=https://s3.regru.cloud
S3_BUCKET=tailly
S3_REGION=ru-central1
S3_ACCESS_KEY=TJ946G2S1Z5FEI3I7DQQ
S3_SECRET_KEY=C2H2aITHRDpek8H921yhnrINZwDoADsjW3F6HURl
MODERATION_ADDR=tailly_censor:50051

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/tailly_clips.iml" filepath="$PROJECT_DIR$/.idea/tailly_clips.iml" />
</modules>
</component>
</project>

9
.idea/tailly_clips.iml generated Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

42
Dockerfile Normal file
View File

@ -0,0 +1,42 @@
FROM golang:1.21-alpine AS builder
# Устанавливаем FFmpeg и зависимости
RUN apk add --no-cache \
ffmpeg \
build-base \
git
WORKDIR /app
# Копируем зависимости
COPY go.mod go.sum ./
RUN go mod download
# Копируем исходный код
COPY . .
# Генерируем gRPC код
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
RUN go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
RUN protoc --go_out=./gen --go_opt=paths=source_relative \
--go-grpc_out=./gen --go-grpc_opt=paths=source_relative \
proto/clip.proto
# Собираем приложение
RUN go build -o clip-service ./cmd/server
FROM alpine:3.18
# Устанавливаем FFmpeg и зависимости времени выполнения
RUN apk add --no-cache \
ffmpeg \
ca-certificates
WORKDIR /app
# Копируем бинарник из builder stage
COPY --from=builder /app/clip-service .
EXPOSE 50054
CMD ["./clip-service"]

548
clips_test.go Normal file
View File

@ -0,0 +1,548 @@
package main
import (
"context"
"database/sql"
"fmt"
"io/ioutil"
"log"
"net"
"os"
"path/filepath"
"tailly_clips/internal/moderation"
"testing"
"time"
"tailly_clips/internal/ffmpeg"
"tailly_clips/internal/handler"
"tailly_clips/internal/repository"
"tailly_clips/internal/service"
"tailly_clips/internal/storage"
"tailly_clips/proto"
"github.com/caarlos0/env/v8"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type RealTestSuite struct {
grpcClient proto.ClipServiceClient
grpcConn *grpc.ClientConn
grpcServer *grpc.Server
testUserID int32
}
type Config struct {
DBHost string `env:"DB_HOST" env-default:"localhost"`
DBPort string `env:"DB_PORT" env-default:"5432"`
DBUser string `env:"DB_USER" env-default:"postgres"`
DBPassword string `env:"DB_PASSWORD" env-default:"postgres"`
DBName string `env:"DB_NAME" env-default:"clip_service"`
S3Endpoint string `env:"S3_ENDPOINT,required"`
S3Bucket string `env:"S3_BUCKET,required"`
S3Region string `env:"S3_REGION,required"`
S3AccessKey string `env:"S3_ACCESS_KEY,required"`
S3SecretKey string `env:"S3_SECRET_KEY,required"`
ModerationAddr string `env:"MODERATION_ADDR" env-default:"localhost:50051"`
}
func loadConfig() (*Config, error) {
// Загружаем .env файл (игнорируем ошибку если файла нет)
_ = godotenv.Load()
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func initDatabase(cfg *Config) (*sql.DB, error) {
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName)
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err = db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
log.Println("Database connected successfully")
return db, nil
}
func setupRealTestSuite(t *testing.T) *RealTestSuite {
// Загружаем конфигурацию из основного .env файла
cfg, err := loadConfig()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Инициализация базы данных
db, err := initDatabase(cfg)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// Инициализация S3 storage
s3Storage := storage.NewS3Storage(storage.S3Config{
Endpoint: cfg.S3Endpoint,
Bucket: cfg.S3Bucket,
Region: cfg.S3Region,
AccessKey: cfg.S3AccessKey,
SecretKey: cfg.S3SecretKey,
})
modClient, err := moderation.NewModerationClient(cfg.ModerationAddr)
if err != nil {
log.Fatalf("Failed to create moderation client: %v", err)
}
// Инициализация сервисов
videoProcessor := ffmpeg.NewVideoProcessor()
clipRepo := repository.NewClipRepository(db)
likeRepo := repository.NewLikeRepository(db)
commentRepo := repository.NewCommentRepository(db)
clipService := service.NewClipService(clipRepo, s3Storage, videoProcessor, modClient)
likeService := service.NewLikeService(likeRepo, clipRepo)
commentService := service.NewCommentService(commentRepo, clipRepo)
grpcHandler := handler.NewGRPCHandler(clipService, likeService, commentService)
// Запускаем gRPC сервер на случайном порту
listener, err := net.Listen("tcp", ":0")
if err != nil {
t.Fatalf("Failed to listen: %v", err)
}
server := grpc.NewServer(
grpc.MaxRecvMsgSize(50*1024*1024), // 50MB
grpc.MaxSendMsgSize(50*1024*1024), // 50MB
)
proto.RegisterClipServiceServer(server, grpcHandler)
go func() {
if err := server.Serve(listener); err != nil {
log.Printf("Server exited with error: %v", err)
}
}()
// Подключаемся к серверу с увеличенным размером сообщения
conn, err := grpc.Dial(listener.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(50*1024*1024),
grpc.MaxCallSendMsgSize(50*1024*1024),
),
)
if err != nil {
t.Fatalf("Failed to dial: %v", err)
}
client := proto.NewClipServiceClient(conn)
// Ждем немного для инициализации сервера
time.Sleep(100 * time.Millisecond)
return &RealTestSuite{
grpcClient: client,
grpcConn: conn,
grpcServer: server,
testUserID: 9999, // Используем специальный ID для тестов
}
}
func (s *RealTestSuite) cleanup() {
if s.grpcServer != nil {
s.grpcServer.Stop()
}
if s.grpcConn != nil {
s.grpcConn.Close()
}
}
func getRealVideoFiles(t *testing.T) []string {
// Укажите путь к директории с реальными видео файлами
videoDir := "./test_videos" // Измените на ваш путь
if _, err := os.Stat(videoDir); os.IsNotExist(err) {
t.Skipf("Video directory %s does not exist, skipping test", videoDir)
return nil
}
files, err := ioutil.ReadDir(videoDir)
if err != nil {
t.Fatalf("Failed to read video directory: %v", err)
}
var videoFiles []string
for _, file := range files {
if !file.IsDir() {
ext := filepath.Ext(file.Name())
if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".mkv" {
videoFiles = append(videoFiles, filepath.Join(videoDir, file.Name()))
}
}
}
if len(videoFiles) == 0 {
t.Skip("No video files found in test directory")
}
// Ограничиваем максимум 10 файлами для теста
if len(videoFiles) > 10 {
videoFiles = videoFiles[:10]
}
return videoFiles
}
func TestRealIntegration_UploadMultipleVideos(t *testing.T) {
suite := setupRealTestSuite(t)
defer suite.cleanup()
ctx := context.Background()
// Получаем реальные видео файлы
videoFiles := getRealVideoFiles(t)
if videoFiles == nil {
return
}
t.Logf("Found %d video files for testing", len(videoFiles))
// Загружаем каждое видео
for i, videoPath := range videoFiles {
t.Run(fmt.Sprintf("Upload_%s", filepath.Base(videoPath)), func(t *testing.T) {
// Читаем видео файл
videoData, err := ioutil.ReadFile(videoPath)
if err != nil {
t.Fatalf("Failed to read video file %s: %v", videoPath, err)
}
// Создаем уникальное название для избежания конфликтов
uniqueTitle := fmt.Sprintf("Test Clip %d - %s - %d",
i+1,
filepath.Base(videoPath),
time.Now().Unix())
// Создаем запрос
req := &proto.CreateClipRequest{
UserId: suite.testUserID,
Title: uniqueTitle,
VideoData: videoData,
FileName: filepath.Base(videoPath),
ContentType: "video/mp4",
}
t.Logf("Uploading %s (%d bytes)", req.FileName, len(videoData))
// Вызываем gRPC метод
startTime := time.Now()
resp, err := suite.grpcClient.CreateClip(ctx, req)
uploadTime := time.Since(startTime)
if err != nil {
t.Fatalf("Failed to create clip: %v", err)
}
t.Logf("Successfully uploaded in %v: %s (ID: %d)",
uploadTime, resp.Clip.Title, resp.Clip.Id)
// Проверяем результат
if resp.Clip == nil {
t.Fatal("Expected clip in response")
}
if resp.Clip.Title != uniqueTitle {
t.Errorf("Expected title %s, got %s", uniqueTitle, resp.Clip.Title)
}
if resp.Clip.AuthorId != suite.testUserID {
t.Errorf("Expected author ID %d, got %d", suite.testUserID, resp.Clip.AuthorId)
}
if resp.Clip.Duration <= 0 {
t.Error("Expected positive duration")
}
if resp.Clip.VideoUrl == "" {
t.Error("Expected video URL")
}
if resp.Clip.ThumbnailUrl == "" {
t.Error("Expected thumbnail URL")
}
t.Logf("Video URL: %s", resp.Clip.VideoUrl)
t.Logf("Thumbnail URL: %s", resp.Clip.ThumbnailUrl)
t.Logf("Duration: %d seconds", resp.Clip.Duration)
})
}
}
func TestRealIntegration_ClipOperations(t *testing.T) {
suite := setupRealTestSuite(t)
defer suite.cleanup()
ctx := context.Background()
// Сначала загружаем тестовое видео
videoFiles := getRealVideoFiles(t)
if videoFiles == nil || len(videoFiles) == 0 {
t.Skip("No video files available for testing")
return
}
videoData, err := ioutil.ReadFile(videoFiles[0])
if err != nil {
t.Fatalf("Failed to read test video: %v", err)
}
// Создаем клип
createResp, err := suite.grpcClient.CreateClip(ctx, &proto.CreateClipRequest{
UserId: suite.testUserID,
Title: fmt.Sprintf("Integration Test Clip - %d", time.Now().Unix()),
VideoData: videoData,
FileName: filepath.Base(videoFiles[0]),
ContentType: "video/mp4",
})
if err != nil {
t.Fatalf("Failed to create clip: %v", err)
}
clipID := createResp.Clip.Id
t.Logf("Created test clip with ID: %d", clipID)
// Тест 1: Получение клипа
t.Run("Get_clip", func(t *testing.T) {
resp, err := suite.grpcClient.GetClip(ctx, &proto.GetClipRequest{ClipId: clipID})
if err != nil {
t.Fatalf("Failed to get clip: %v", err)
}
if resp.Clip.Id != clipID {
t.Errorf("Expected clip ID %d, got %d", clipID, resp.Clip.Id)
}
t.Logf("Successfully retrieved clip: %s", resp.Clip.Title)
})
// Тест 2: Лайк клипа
t.Run("Like_clip", func(t *testing.T) {
_, err := suite.grpcClient.LikeClip(ctx, &proto.LikeClipRequest{
ClipId: clipID,
UserId: suite.testUserID,
})
if err != nil {
t.Fatalf("Failed to like clip: %v", err)
}
// Проверяем что лайк добавился
checkResp, err := suite.grpcClient.CheckIfLiked(ctx, &proto.CheckIfLikedRequest{
ClipId: clipID,
UserId: suite.testUserID,
})
if err != nil {
t.Fatalf("Failed to check like: %v", err)
}
if !checkResp.IsLiked {
t.Error("Expected clip to be liked")
}
t.Logf("Successfully liked clip %d", clipID)
})
// Тест 3: Комментарий
t.Run("Add_comment", func(t *testing.T) {
commentText := fmt.Sprintf("Test comment at %s", time.Now().Format(time.RFC3339))
commentResp, err := suite.grpcClient.CreateComment(ctx, &proto.CreateCommentRequest{
ClipId: clipID,
UserId: suite.testUserID,
Content: commentText,
})
if err != nil {
t.Fatalf("Failed to add comment: %v", err)
}
if commentResp.Comment.Content != commentText {
t.Errorf("Expected comment '%s', got '%s'", commentText, commentResp.Comment.Content)
}
t.Logf("Successfully added comment with ID: %d", commentResp.Comment.Id)
})
// Тест 4: Получение комментариев
t.Run("Get_comments", func(t *testing.T) {
resp, err := suite.grpcClient.GetClipComments(ctx, &proto.GetClipCommentsRequest{
ClipId: clipID,
Limit: 10,
Offset: 0,
})
if err != nil {
t.Fatalf("Failed to get comments: %v", err)
}
if len(resp.Comments) == 0 {
t.Error("Expected at least one comment")
} else {
t.Logf("Found %d comments for clip %d", len(resp.Comments), clipID)
}
})
// Тест 5: Получение списка клипов пользователя
t.Run("Get_user_clips", func(t *testing.T) {
resp, err := suite.grpcClient.GetUserClips(ctx, &proto.GetUserClipsRequest{
UserId: suite.testUserID,
Limit: 10,
Offset: 0,
})
if err != nil {
t.Fatalf("Failed to get user clips: %v", err)
}
t.Logf("User %d has %d clips (total: %d)",
suite.testUserID, len(resp.Clips), resp.TotalCount)
})
// Тест 6: Получение всех клипов
t.Run("Get_all_clips", func(t *testing.T) {
resp, err := suite.grpcClient.GetClips(ctx, &proto.GetClipsRequest{
Limit: 20,
Offset: 0,
})
if err != nil {
t.Fatalf("Failed to get all clips: %v", err)
}
t.Logf("Total clips in system: %d", resp.TotalCount)
if len(resp.Clips) > 0 {
t.Logf("Retrieved %d clips", len(resp.Clips))
}
})
// NOTE: Удаление клипа закомментировано, так как данные не должны удаляться
/*
// Тест 7: Удаление клипа
t.Run("Delete_clip", func(t *testing.T) {
_, err := suite.grpcClient.DeleteClip(ctx, &proto.DeleteClipRequest{
ClipId: clipID,
UserId: suite.testUserID,
})
if err != nil {
t.Fatalf("Failed to delete clip: %v", err)
}
t.Logf("Successfully deleted clip %d", clipID)
})
*/
}
func TestRealIntegration_PerformanceTest(t *testing.T) {
suite := setupRealTestSuite(t)
defer suite.cleanup()
ctx := context.Background()
videoFiles := getRealVideoFiles(t)
if videoFiles == nil || len(videoFiles) < 2 {
t.Skip("Not enough video files for performance test")
return
}
// Тестируем производительность на первом видео
videoPath := videoFiles[0]
videoData, err := ioutil.ReadFile(videoPath)
if err != nil {
t.Fatalf("Failed to read test video: %v", err)
}
t.Run("Upload_performance", func(t *testing.T) {
// Замеряем время загрузки
startTime := time.Now()
_, err := suite.grpcClient.CreateClip(ctx, &proto.CreateClipRequest{
UserId: suite.testUserID,
Title: fmt.Sprintf("Performance Test - %d", time.Now().UnixNano()),
VideoData: videoData,
FileName: filepath.Base(videoPath),
ContentType: "video/mp4",
})
uploadTime := time.Since(startTime)
if err != nil {
t.Fatalf("Upload failed: %v", err)
}
t.Logf("Upload performance: %v for %d bytes (%.2f MB/s)",
uploadTime,
len(videoData),
float64(len(videoData))/1024/1024/uploadTime.Seconds())
})
t.Run("Concurrent_uploads", func(t *testing.T) {
// Тестируем конкурентные загрузки (ограниченное количество)
concurrentUploads := 3
if len(videoFiles) < concurrentUploads {
concurrentUploads = len(videoFiles)
}
results := make(chan time.Duration, concurrentUploads)
errors := make(chan error, concurrentUploads)
for i := 0; i < concurrentUploads; i++ {
go func(idx int) {
videoData, err := ioutil.ReadFile(videoFiles[idx])
if err != nil {
errors <- err
return
}
startTime := time.Now()
_, err = suite.grpcClient.CreateClip(ctx, &proto.CreateClipRequest{
UserId: suite.testUserID,
Title: fmt.Sprintf("Concurrent %d - %d", idx, time.Now().UnixNano()),
VideoData: videoData,
FileName: filepath.Base(videoFiles[idx]),
ContentType: "video/mp4",
})
uploadTime := time.Since(startTime)
if err != nil {
errors <- err
return
}
results <- uploadTime
}(i)
}
// Собираем результаты
var totalTime time.Duration
for i := 0; i < concurrentUploads; i++ {
select {
case duration := <-results:
totalTime += duration
t.Logf("Concurrent upload %d: %v", i+1, duration)
case err := <-errors:
t.Errorf("Concurrent upload failed: %v", err)
case <-time.After(2 * time.Minute):
t.Error("Concurrent upload timeout")
}
}
avgTime := totalTime / time.Duration(concurrentUploads)
t.Logf("Average concurrent upload time: %v", avgTime)
})
}

124
cmd/server/main.go Normal file
View File

@ -0,0 +1,124 @@
package main
import (
"database/sql"
"fmt"
"log"
"net"
"tailly_clips/internal/ffmpeg"
"tailly_clips/internal/handler"
"tailly_clips/internal/moderation"
"tailly_clips/internal/repository"
"tailly_clips/internal/service"
"tailly_clips/internal/storage"
"tailly_clips/proto"
"time"
"github.com/caarlos0/env/v8"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
"google.golang.org/grpc"
)
// Config структура конфигурации
type Config struct {
DBHost string `env:"DB_HOST" env-default:"localhost"`
DBPort string `env:"DB_PORT" env-default:"5432"`
DBUser string `env:"DB_USER" env-default:"postgres"`
DBPassword string `env:"DB_PASSWORD" env-default:"postgres"`
DBName string `env:"DB_NAME" env-default:"clip_service"`
S3Endpoint string `env:"S3_ENDPOINT,required"`
S3Bucket string `env:"S3_BUCKET,required"`
S3Region string `env:"S3_REGION,required"`
S3AccessKey string `env:"S3_ACCESS_KEY,required"`
S3SecretKey string `env:"S3_SECRET_KEY,required"`
ModerationAddr string `env:"MODERATION_ADDR" env-default:"localhost:50051"`
}
func main() {
// Загрузка конфигурации
cfg, err := loadConfig()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// Инициализация базы данных
db, err := initDatabase(cfg)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
// Инициализация S3 storage
s3Storage := storage.NewS3Storage(storage.S3Config{
Endpoint: cfg.S3Endpoint,
Bucket: cfg.S3Bucket,
Region: cfg.S3Region,
AccessKey: cfg.S3AccessKey,
SecretKey: cfg.S3SecretKey,
})
modClient, err := moderation.NewModerationClient(cfg.ModerationAddr)
if err != nil {
log.Fatalf("Failed to create moderation client: %v", err)
}
defer modClient.Close()
videoProcessor := ffmpeg.NewVideoProcessor()
clipRepo := repository.NewClipRepository(db)
likeRepo := repository.NewLikeRepository(db)
commentRepo := repository.NewCommentRepository(db)
clipService := service.NewClipService(clipRepo, s3Storage, videoProcessor, modClient)
likeService := service.NewLikeService(likeRepo, clipRepo)
commentService := service.NewCommentService(commentRepo, clipRepo)
grpcHandler := handler.NewGRPCHandler(clipService, likeService, commentService)
server := grpc.NewServer()
proto.RegisterClipServiceServer(server, grpcHandler)
lis, err := net.Listen("tcp", ":50054")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
log.Println("Clip service started on :50054")
if err := server.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
func loadConfig() (*Config, error) {
// Загружаем .env файл (игнорируем ошибку если файла нет)
_ = godotenv.Load()
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func initDatabase(cfg *Config) (*sql.DB, error) {
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName)
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if err = db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
log.Println("Database connected successfully")
return db, nil
}

24
go.mod Normal file
View File

@ -0,0 +1,24 @@
module tailly_clips
go 1.25
require (
github.com/aws/aws-sdk-go v1.49.6
github.com/caarlos0/env/v8 v8.0.0
github.com/joho/godotenv v1.5.1
google.golang.org/grpc v1.67.0
google.golang.org/protobuf v1.36.8
)
require github.com/lib/pq v1.10.9
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

36
go.sum Normal file
View File

@ -0,0 +1,36 @@
github.com/aws/aws-sdk-go v1.49.6 h1:yNldzF5kzLBRvKlKz1S0bkvc2+04R1kt13KfBWQBfFA=
github.com/aws/aws-sdk-go v1.49.6/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0=
github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw=
google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

40
internal/domain/models.go Normal file
View File

@ -0,0 +1,40 @@
package domain
import "time"
type Clip struct {
ID int `json:"id"`
Title string `json:"title"`
VideoURL string `json:"video_url"`
ThumbnailURL string `json:"thumbnail_url"`
Duration int `json:"duration"` // seconds
AuthorID int `json:"author_id"`
LikesCount int `json:"likes_count"`
CommentsCount int `json:"comments_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ClipLike struct {
ID int `json:"id"`
ClipID int `json:"clip_id"`
UserID int `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
}
type ClipComment struct {
ID int `json:"id"`
ClipID int `json:"clip_id"`
AuthorID int `json:"author_id"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateClipRequest struct {
UserID int
Title string
VideoData []byte
FileName string
ContentType string
}

View File

@ -0,0 +1,114 @@
package ffmpeg
import (
"bytes"
"fmt"
"os/exec"
"strconv"
"strings"
)
type VideoProcessor interface {
GenerateThumbnail(videoData []byte) ([]byte, error)
GetDuration(videoData []byte) (int, error)
TrimVideo(videoData []byte, maxDuration int) ([]byte, error)
}
type videoProcessor struct {
}
func NewVideoProcessor() VideoProcessor {
return &videoProcessor{}
}
func (vp *videoProcessor) GenerateThumbnail(videoData []byte) ([]byte, error) {
cmd := exec.Command("ffmpeg",
"-i", "pipe:0", // читаем из stdin
"-ss", "00:00:01",
"-vframes", "1",
"-q:v", "2",
"-f", "image2pipe", // вывод в pipe
"-c:v", "mjpeg",
"pipe:1", // пишем в stdout
)
cmd.Stdin = bytes.NewReader(videoData)
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output // capture stderr for error messages
err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("ffmpeg failed: %s, error: %w", output.String(), err)
}
if output.Len() == 0 {
return nil, fmt.Errorf("thumbnail generation produced empty output")
}
return output.Bytes(), nil
}
func (vp *videoProcessor) GetDuration(videoData []byte) (int, error) {
cmd := exec.Command("ffprobe",
"-i", "pipe:0",
"-show_entries", "format=duration",
"-v", "quiet",
"-of", "csv=p=0",
)
cmd.Stdin = bytes.NewReader(videoData)
output, err := cmd.Output()
if err != nil {
debugCmd := exec.Command("ffprobe", "-i", "pipe:0")
debugCmd.Stdin = bytes.NewReader(videoData)
debugOutput, _ := debugCmd.CombinedOutput()
return 0, fmt.Errorf("ffprobe failed: %w, debug output: %s", err, string(debugOutput))
}
durationStr := strings.TrimSpace(string(output))
duration, err := strconv.ParseFloat(durationStr, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse duration '%s': %w", durationStr, err)
}
return int(duration), nil
}
func (vp *videoProcessor) TrimVideo(videoData []byte, maxDuration int) ([]byte, error) {
// Сначала получаем длительность
duration, err := vp.GetDuration(videoData)
if err != nil {
return nil, fmt.Errorf("failed to get video duration: %w", err)
}
// Если видео короче или равно лимиту, возвращаем оригинал
if duration <= maxDuration {
return videoData, nil
}
// Обрезаем видео
cmd := exec.Command("ffmpeg",
"-i", "pipe:0",
"-t", strconv.Itoa(maxDuration),
"-c", "copy",
"-f", "mp4",
"pipe:1",
)
cmd.Stdin = bytes.NewReader(videoData)
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
err = cmd.Run()
if err != nil {
return nil, fmt.Errorf("ffmpeg trim failed: %s, error: %w", output.String(), err)
}
if output.Len() == 0 {
return nil, fmt.Errorf("trimmed video is empty")
}
return output.Bytes(), nil
}

View File

@ -0,0 +1,243 @@
package handler
import (
"context"
"tailly_clips/internal/domain"
"tailly_clips/internal/service"
"tailly_clips/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type GRPCHandler struct {
proto.UnimplementedClipServiceServer
clipService service.ClipService
likeService service.LikeService
commentService service.CommentService
}
func NewGRPCHandler(
clipService service.ClipService,
likeService service.LikeService,
commentService service.CommentService,
) *GRPCHandler {
return &GRPCHandler{
clipService: clipService,
likeService: likeService,
commentService: commentService,
}
}
func (h *GRPCHandler) CreateClip(ctx context.Context, req *proto.CreateClipRequest) (*proto.CreateClipResponse, error) {
createReq := domain.CreateClipRequest{
UserID: int(req.UserId),
Title: req.Title,
VideoData: req.VideoData,
FileName: req.FileName,
ContentType: req.ContentType,
}
clip, err := h.clipService.CreateClip(ctx, createReq)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create clip: %v", err)
}
return &proto.CreateClipResponse{
Clip: h.clipToProto(clip),
}, nil
}
func (h *GRPCHandler) GetClip(ctx context.Context, req *proto.GetClipRequest) (*proto.GetClipResponse, error) {
clip, err := h.clipService.GetClip(ctx, int(req.ClipId))
if err != nil {
return nil, status.Errorf(codes.NotFound, "clip not found: %v", err)
}
return &proto.GetClipResponse{
Clip: h.clipToProto(clip),
}, nil
}
func (h *GRPCHandler) GetUserClips(ctx context.Context, req *proto.GetUserClipsRequest) (*proto.GetUserClipsResponse, error) {
clips, totalCount, err := h.clipService.GetUserClips(ctx, int(req.UserId), int(req.Limit), int(req.Offset))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user clips: %v", err)
}
return &proto.GetUserClipsResponse{
Clips: h.clipsToProto(clips),
TotalCount: int32(totalCount),
}, nil
}
func (h *GRPCHandler) GetClips(ctx context.Context, req *proto.GetClipsRequest) (*proto.GetClipsResponse, error) {
clips, totalCount, err := h.clipService.GetClips(ctx, int(req.Limit), int(req.Offset))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get clips: %v", err)
}
return &proto.GetClipsResponse{
Clips: h.clipsToProto(clips),
TotalCount: int32(totalCount),
}, nil
}
func (h *GRPCHandler) DeleteClip(ctx context.Context, req *proto.DeleteClipRequest) (*emptypb.Empty, error) {
err := h.clipService.DeleteClip(ctx, int(req.ClipId), int(req.UserId))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete clip: %v", err)
}
return &emptypb.Empty{}, nil
}
func (h *GRPCHandler) LikeClip(ctx context.Context, req *proto.LikeClipRequest) (*proto.LikeClipResponse, error) {
like, err := h.likeService.LikeClip(ctx, int(req.ClipId), int(req.UserId))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to like clip: %v", err)
}
return &proto.LikeClipResponse{
Like: h.likeToProto(like),
}, nil
}
func (h *GRPCHandler) UnlikeClip(ctx context.Context, req *proto.UnlikeClipRequest) (*emptypb.Empty, error) {
err := h.likeService.UnlikeClip(ctx, int(req.ClipId), int(req.UserId))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to unlike clip: %v", err)
}
return &emptypb.Empty{}, nil
}
func (h *GRPCHandler) GetClipLikes(ctx context.Context, req *proto.GetClipLikesRequest) (*proto.GetClipLikesResponse, error) {
likes, totalCount, err := h.likeService.GetClipLikes(ctx, int(req.ClipId), int(req.Limit), int(req.Offset))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get clip likes: %v", err)
}
return &proto.GetClipLikesResponse{
Likes: h.likesToProto(likes),
TotalCount: int32(totalCount),
}, nil
}
func (h *GRPCHandler) CheckIfLiked(ctx context.Context, req *proto.CheckIfLikedRequest) (*proto.CheckIfLikedResponse, error) {
isLiked, err := h.likeService.CheckIfLiked(ctx, int(req.ClipId), int(req.UserId))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to check like status: %v", err)
}
return &proto.CheckIfLikedResponse{
IsLiked: isLiked,
}, nil
}
func (h *GRPCHandler) CreateComment(ctx context.Context, req *proto.CreateCommentRequest) (*proto.CreateCommentResponse, error) {
comment, err := h.commentService.CreateComment(ctx, int(req.ClipId), int(req.UserId), req.Content)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create comment: %v", err)
}
return &proto.CreateCommentResponse{
Comment: h.commentToProto(comment),
}, nil
}
func (h *GRPCHandler) GetClipComments(ctx context.Context, req *proto.GetClipCommentsRequest) (*proto.GetClipCommentsResponse, error) {
comments, totalCount, err := h.commentService.GetClipComments(ctx, int(req.ClipId), int(req.Limit), int(req.Offset))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get clip comments: %v", err)
}
return &proto.GetClipCommentsResponse{
Comments: h.commentsToProto(comments),
TotalCount: int32(totalCount),
}, nil
}
func (h *GRPCHandler) DeleteComment(ctx context.Context, req *proto.DeleteCommentRequest) (*emptypb.Empty, error) {
err := h.commentService.DeleteComment(ctx, int(req.CommentId), int(req.UserId))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to delete comment: %v", err)
}
return &emptypb.Empty{}, nil
}
// Вспомогательные методы для конвертации
func (h *GRPCHandler) clipToProto(clip *domain.Clip) *proto.Clip {
if clip == nil {
return nil
}
return &proto.Clip{
Id: int32(clip.ID),
Title: clip.Title,
VideoUrl: clip.VideoURL,
ThumbnailUrl: clip.ThumbnailURL,
Duration: int32(clip.Duration),
AuthorId: int32(clip.AuthorID),
LikesCount: int32(clip.LikesCount),
CommentsCount: int32(clip.CommentsCount),
CreatedAt: timestamppb.New(clip.CreatedAt),
UpdatedAt: timestamppb.New(clip.UpdatedAt),
}
}
func (h *GRPCHandler) clipsToProto(clips []*domain.Clip) []*proto.Clip {
var protoClips []*proto.Clip
for _, clip := range clips {
protoClips = append(protoClips, h.clipToProto(clip))
}
return protoClips
}
func (h *GRPCHandler) likeToProto(like *domain.ClipLike) *proto.ClipLike {
if like == nil {
return nil
}
return &proto.ClipLike{
Id: int32(like.ID),
ClipId: int32(like.ClipID),
UserId: int32(like.UserID),
CreatedAt: timestamppb.New(like.CreatedAt),
}
}
func (h *GRPCHandler) likesToProto(likes []*domain.ClipLike) []*proto.ClipLike {
var protoLikes []*proto.ClipLike
for _, like := range likes {
protoLikes = append(protoLikes, h.likeToProto(like))
}
return protoLikes
}
func (h *GRPCHandler) commentToProto(comment *domain.ClipComment) *proto.ClipComment {
if comment == nil {
return nil
}
return &proto.ClipComment{
Id: int32(comment.ID),
ClipId: int32(comment.ClipID),
AuthorId: int32(comment.AuthorID),
Content: comment.Content,
CreatedAt: timestamppb.New(comment.CreatedAt),
UpdatedAt: timestamppb.New(comment.UpdatedAt),
}
}
func (h *GRPCHandler) commentsToProto(comments []*domain.ClipComment) []*proto.ClipComment {
var protoComments []*proto.ClipComment
for _, comment := range comments {
protoComments = append(protoComments, h.commentToProto(comment))
}
return protoComments
}

View File

@ -0,0 +1,39 @@
package moderation
import (
"context"
pb "tailly_clips/internal/moderation/proto"
"google.golang.org/grpc"
)
type ModerationClient struct {
conn *grpc.ClientConn
client pb.ModerationServiceClient
}
func NewModerationClient(addr string) (*ModerationClient, error) {
conn, err := grpc.Dial(addr, grpc.WithInsecure())
if err != nil {
return nil, err
}
return &ModerationClient{
conn: conn,
client: pb.NewModerationServiceClient(conn),
}, nil
}
func (c *ModerationClient) Close() {
c.conn.Close()
}
func (c *ModerationClient) CheckVideoUrl(ctx context.Context, videoUrl string) (bool, error) {
resp, err := c.client.CheckVideo(ctx, &pb.VideoRequest{
VideoUrl: videoUrl,
})
if err != nil {
return false, err
}
return resp.OverallDecision == "allowed", nil
}

View File

@ -0,0 +1,400 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v3.21.12
// source: moderation.proto
package __
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ImageRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ImageData []byte `protobuf:"bytes,1,opt,name=image_data,json=imageData,proto3" json:"image_data,omitempty"`
ImageUrl string `protobuf:"bytes,2,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ImageRequest) Reset() {
*x = ImageRequest{}
mi := &file_moderation_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ImageRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ImageRequest) ProtoMessage() {}
func (x *ImageRequest) ProtoReflect() protoreflect.Message {
mi := &file_moderation_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ImageRequest.ProtoReflect.Descriptor instead.
func (*ImageRequest) Descriptor() ([]byte, []int) {
return file_moderation_proto_rawDescGZIP(), []int{0}
}
func (x *ImageRequest) GetImageData() []byte {
if x != nil {
return x.ImageData
}
return nil
}
func (x *ImageRequest) GetImageUrl() string {
if x != nil {
return x.ImageUrl
}
return ""
}
type VideoRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
VideoUrl string `protobuf:"bytes,1,opt,name=video_url,json=videoUrl,proto3" json:"video_url,omitempty"`
FrameSampleRate int32 `protobuf:"varint,2,opt,name=frame_sample_rate,json=frameSampleRate,proto3" json:"frame_sample_rate,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *VideoRequest) Reset() {
*x = VideoRequest{}
mi := &file_moderation_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *VideoRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*VideoRequest) ProtoMessage() {}
func (x *VideoRequest) ProtoReflect() protoreflect.Message {
mi := &file_moderation_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use VideoRequest.ProtoReflect.Descriptor instead.
func (*VideoRequest) Descriptor() ([]byte, []int) {
return file_moderation_proto_rawDescGZIP(), []int{1}
}
func (x *VideoRequest) GetVideoUrl() string {
if x != nil {
return x.VideoUrl
}
return ""
}
func (x *VideoRequest) GetFrameSampleRate() int32 {
if x != nil {
return x.FrameSampleRate
}
return 0
}
type ModerationResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
OverallDecision string `protobuf:"bytes,1,opt,name=overall_decision,json=overallDecision,proto3" json:"overall_decision,omitempty"`
Predictions []*Prediction `protobuf:"bytes,2,rep,name=predictions,proto3" json:"predictions,omitempty"`
Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ModerationResponse) Reset() {
*x = ModerationResponse{}
mi := &file_moderation_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ModerationResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ModerationResponse) ProtoMessage() {}
func (x *ModerationResponse) ProtoReflect() protoreflect.Message {
mi := &file_moderation_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ModerationResponse.ProtoReflect.Descriptor instead.
func (*ModerationResponse) Descriptor() ([]byte, []int) {
return file_moderation_proto_rawDescGZIP(), []int{2}
}
func (x *ModerationResponse) GetOverallDecision() string {
if x != nil {
return x.OverallDecision
}
return ""
}
func (x *ModerationResponse) GetPredictions() []*Prediction {
if x != nil {
return x.Predictions
}
return nil
}
func (x *ModerationResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
type Prediction struct {
state protoimpl.MessageState `protogen:"open.v1"`
Category string `protobuf:"bytes,1,opt,name=category,proto3" json:"category,omitempty"`
Score float32 `protobuf:"fixed32,2,opt,name=score,proto3" json:"score,omitempty"`
Decision string `protobuf:"bytes,3,opt,name=decision,proto3" json:"decision,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Prediction) Reset() {
*x = Prediction{}
mi := &file_moderation_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Prediction) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Prediction) ProtoMessage() {}
func (x *Prediction) ProtoReflect() protoreflect.Message {
mi := &file_moderation_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Prediction.ProtoReflect.Descriptor instead.
func (*Prediction) Descriptor() ([]byte, []int) {
return file_moderation_proto_rawDescGZIP(), []int{3}
}
func (x *Prediction) GetCategory() string {
if x != nil {
return x.Category
}
return ""
}
func (x *Prediction) GetScore() float32 {
if x != nil {
return x.Score
}
return 0
}
func (x *Prediction) GetDecision() string {
if x != nil {
return x.Decision
}
return ""
}
type ModerationSettings struct {
state protoimpl.MessageState `protogen:"open.v1"`
CategoryThresholds map[string]float32 `protobuf:"bytes,1,rep,name=category_thresholds,json=categoryThresholds,proto3" json:"category_thresholds,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"fixed32,2,opt,name=value"`
AlwaysBlockCategories []string `protobuf:"bytes,2,rep,name=always_block_categories,json=alwaysBlockCategories,proto3" json:"always_block_categories,omitempty"`
AlwaysAllowCategories []string `protobuf:"bytes,3,rep,name=always_allow_categories,json=alwaysAllowCategories,proto3" json:"always_allow_categories,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ModerationSettings) Reset() {
*x = ModerationSettings{}
mi := &file_moderation_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ModerationSettings) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ModerationSettings) ProtoMessage() {}
func (x *ModerationSettings) ProtoReflect() protoreflect.Message {
mi := &file_moderation_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ModerationSettings.ProtoReflect.Descriptor instead.
func (*ModerationSettings) Descriptor() ([]byte, []int) {
return file_moderation_proto_rawDescGZIP(), []int{4}
}
func (x *ModerationSettings) GetCategoryThresholds() map[string]float32 {
if x != nil {
return x.CategoryThresholds
}
return nil
}
func (x *ModerationSettings) GetAlwaysBlockCategories() []string {
if x != nil {
return x.AlwaysBlockCategories
}
return nil
}
func (x *ModerationSettings) GetAlwaysAllowCategories() []string {
if x != nil {
return x.AlwaysAllowCategories
}
return nil
}
var File_moderation_proto protoreflect.FileDescriptor
const file_moderation_proto_rawDesc = "" +
"\n" +
"\x10moderation.proto\x12\n" +
"moderation\"J\n" +
"\fImageRequest\x12\x1d\n" +
"\n" +
"image_data\x18\x01 \x01(\fR\timageData\x12\x1b\n" +
"\timage_url\x18\x02 \x01(\tR\bimageUrl\"W\n" +
"\fVideoRequest\x12\x1b\n" +
"\tvideo_url\x18\x01 \x01(\tR\bvideoUrl\x12*\n" +
"\x11frame_sample_rate\x18\x02 \x01(\x05R\x0fframeSampleRate\"\x93\x01\n" +
"\x12ModerationResponse\x12)\n" +
"\x10overall_decision\x18\x01 \x01(\tR\x0foverallDecision\x128\n" +
"\vpredictions\x18\x02 \x03(\v2\x16.moderation.PredictionR\vpredictions\x12\x18\n" +
"\amessage\x18\x03 \x01(\tR\amessage\"Z\n" +
"\n" +
"Prediction\x12\x1a\n" +
"\bcategory\x18\x01 \x01(\tR\bcategory\x12\x14\n" +
"\x05score\x18\x02 \x01(\x02R\x05score\x12\x1a\n" +
"\bdecision\x18\x03 \x01(\tR\bdecision\"\xb4\x02\n" +
"\x12ModerationSettings\x12g\n" +
"\x13category_thresholds\x18\x01 \x03(\v26.moderation.ModerationSettings.CategoryThresholdsEntryR\x12categoryThresholds\x126\n" +
"\x17always_block_categories\x18\x02 \x03(\tR\x15alwaysBlockCategories\x126\n" +
"\x17always_allow_categories\x18\x03 \x03(\tR\x15alwaysAllowCategories\x1aE\n" +
"\x17CategoryThresholdsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\x02R\x05value:\x028\x012\xa7\x01\n" +
"\x11ModerationService\x12H\n" +
"\n" +
"CheckImage\x12\x18.moderation.ImageRequest\x1a\x1e.moderation.ModerationResponse\"\x00\x12H\n" +
"\n" +
"CheckVideo\x12\x18.moderation.VideoRequest\x1a\x1e.moderation.ModerationResponse\"\x00B\x03Z\x01.b\x06proto3"
var (
file_moderation_proto_rawDescOnce sync.Once
file_moderation_proto_rawDescData []byte
)
func file_moderation_proto_rawDescGZIP() []byte {
file_moderation_proto_rawDescOnce.Do(func() {
file_moderation_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_moderation_proto_rawDesc), len(file_moderation_proto_rawDesc)))
})
return file_moderation_proto_rawDescData
}
var file_moderation_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_moderation_proto_goTypes = []any{
(*ImageRequest)(nil), // 0: moderation.ImageRequest
(*VideoRequest)(nil), // 1: moderation.VideoRequest
(*ModerationResponse)(nil), // 2: moderation.ModerationResponse
(*Prediction)(nil), // 3: moderation.Prediction
(*ModerationSettings)(nil), // 4: moderation.ModerationSettings
nil, // 5: moderation.ModerationSettings.CategoryThresholdsEntry
}
var file_moderation_proto_depIdxs = []int32{
3, // 0: moderation.ModerationResponse.predictions:type_name -> moderation.Prediction
5, // 1: moderation.ModerationSettings.category_thresholds:type_name -> moderation.ModerationSettings.CategoryThresholdsEntry
0, // 2: moderation.ModerationService.CheckImage:input_type -> moderation.ImageRequest
1, // 3: moderation.ModerationService.CheckVideo:input_type -> moderation.VideoRequest
2, // 4: moderation.ModerationService.CheckImage:output_type -> moderation.ModerationResponse
2, // 5: moderation.ModerationService.CheckVideo:output_type -> moderation.ModerationResponse
4, // [4:6] is the sub-list for method output_type
2, // [2:4] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_moderation_proto_init() }
func file_moderation_proto_init() {
if File_moderation_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_moderation_proto_rawDesc), len(file_moderation_proto_rawDesc)),
NumEnums: 0,
NumMessages: 6,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_moderation_proto_goTypes,
DependencyIndexes: file_moderation_proto_depIdxs,
MessageInfos: file_moderation_proto_msgTypes,
}.Build()
File_moderation_proto = out.File
file_moderation_proto_goTypes = nil
file_moderation_proto_depIdxs = nil
}

View File

@ -0,0 +1,37 @@
syntax = "proto3";
package moderation;
option go_package = ".";
service ModerationService {
rpc CheckImage (ImageRequest) returns (ModerationResponse) {}
rpc CheckVideo (VideoRequest) returns (ModerationResponse) {}
}
message ImageRequest {
bytes image_data = 1;
string image_url = 2;
}
message VideoRequest {
string video_url = 1;
int32 frame_sample_rate = 2;
}
message ModerationResponse {
string overall_decision = 1;
repeated Prediction predictions = 2;
string message = 3;
}
message Prediction {
string category = 1;
float score = 2;
string decision = 3;
}
message ModerationSettings {
map<string, float> category_thresholds = 1;
repeated string always_block_categories = 2;
repeated string always_allow_categories = 3;
}

View File

@ -0,0 +1,159 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// source: moderation.proto
package __
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
ModerationService_CheckImage_FullMethodName = "/moderation.ModerationService/CheckImage"
ModerationService_CheckVideo_FullMethodName = "/moderation.ModerationService/CheckVideo"
)
// ModerationServiceClient is the client API for ModerationService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type ModerationServiceClient interface {
CheckImage(ctx context.Context, in *ImageRequest, opts ...grpc.CallOption) (*ModerationResponse, error)
CheckVideo(ctx context.Context, in *VideoRequest, opts ...grpc.CallOption) (*ModerationResponse, error)
}
type moderationServiceClient struct {
cc grpc.ClientConnInterface
}
func NewModerationServiceClient(cc grpc.ClientConnInterface) ModerationServiceClient {
return &moderationServiceClient{cc}
}
func (c *moderationServiceClient) CheckImage(ctx context.Context, in *ImageRequest, opts ...grpc.CallOption) (*ModerationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ModerationResponse)
err := c.cc.Invoke(ctx, ModerationService_CheckImage_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *moderationServiceClient) CheckVideo(ctx context.Context, in *VideoRequest, opts ...grpc.CallOption) (*ModerationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ModerationResponse)
err := c.cc.Invoke(ctx, ModerationService_CheckVideo_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ModerationServiceServer is the server API for ModerationService service.
// All implementations must embed UnimplementedModerationServiceServer
// for forward compatibility.
type ModerationServiceServer interface {
CheckImage(context.Context, *ImageRequest) (*ModerationResponse, error)
CheckVideo(context.Context, *VideoRequest) (*ModerationResponse, error)
mustEmbedUnimplementedModerationServiceServer()
}
// UnimplementedModerationServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedModerationServiceServer struct{}
func (UnimplementedModerationServiceServer) CheckImage(context.Context, *ImageRequest) (*ModerationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckImage not implemented")
}
func (UnimplementedModerationServiceServer) CheckVideo(context.Context, *VideoRequest) (*ModerationResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckVideo not implemented")
}
func (UnimplementedModerationServiceServer) mustEmbedUnimplementedModerationServiceServer() {}
func (UnimplementedModerationServiceServer) testEmbeddedByValue() {}
// UnsafeModerationServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ModerationServiceServer will
// result in compilation errors.
type UnsafeModerationServiceServer interface {
mustEmbedUnimplementedModerationServiceServer()
}
func RegisterModerationServiceServer(s grpc.ServiceRegistrar, srv ModerationServiceServer) {
// If the following call pancis, it indicates UnimplementedModerationServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&ModerationService_ServiceDesc, srv)
}
func _ModerationService_CheckImage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ImageRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ModerationServiceServer).CheckImage(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ModerationService_CheckImage_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ModerationServiceServer).CheckImage(ctx, req.(*ImageRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ModerationService_CheckVideo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(VideoRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ModerationServiceServer).CheckVideo(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ModerationService_CheckVideo_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ModerationServiceServer).CheckVideo(ctx, req.(*VideoRequest))
}
return interceptor(ctx, in, info, handler)
}
// ModerationService_ServiceDesc is the grpc.ServiceDesc for ModerationService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ModerationService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "moderation.ModerationService",
HandlerType: (*ModerationServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "CheckImage",
Handler: _ModerationService_CheckImage_Handler,
},
{
MethodName: "CheckVideo",
Handler: _ModerationService_CheckVideo_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "moderation.proto",
}

View File

@ -0,0 +1,335 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"tailly_clips/internal/domain"
"time"
)
var (
ErrClipNotFound = errors.New("clip not found")
)
type ClipRepository interface {
Create(ctx context.Context, clip *domain.Clip) error
GetByID(ctx context.Context, id int) (*domain.Clip, error)
GetByAuthorID(ctx context.Context, authorID, limit, offset int) ([]*domain.Clip, int, error)
GetAll(ctx context.Context, limit, offset int) ([]*domain.Clip, int, error)
Delete(ctx context.Context, id int) error
IncrementLikesCount(ctx context.Context, clipID int) error
DecrementLikesCount(ctx context.Context, clipID int) error
IncrementCommentsCount(ctx context.Context, clipID int) error
DecrementCommentsCount(ctx context.Context, clipID int) error
GetClipURLs(ctx context.Context, clipID int) (string, string, error)
GetClipWithURLs(ctx context.Context, clipID int) (*domain.Clip, error)
}
type clipRepository struct {
db *sql.DB
}
func NewClipRepository(db *sql.DB) ClipRepository {
return &clipRepository{db: db}
}
func (r *clipRepository) Create(ctx context.Context, clip *domain.Clip) error {
query := `
INSERT INTO clips (title, video_url, thumbnail_url, duration, author_id, likes_count, comments_count, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`
err := r.db.QueryRowContext(ctx, query,
clip.Title,
clip.VideoURL,
clip.ThumbnailURL,
clip.Duration,
clip.AuthorID,
clip.LikesCount,
clip.CommentsCount,
clip.CreatedAt,
clip.UpdatedAt,
).Scan(&clip.ID)
if err != nil {
return fmt.Errorf("failed to create clip: %w", err)
}
return nil
}
func (r *clipRepository) GetByID(ctx context.Context, id int) (*domain.Clip, error) {
query := `
SELECT id, title, video_url, thumbnail_url, duration, author_id,
likes_count, comments_count, created_at, updated_at
FROM clips
WHERE id = $1 AND deleted_at IS NULL
`
clip := &domain.Clip{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&clip.ID,
&clip.Title,
&clip.VideoURL,
&clip.ThumbnailURL,
&clip.Duration,
&clip.AuthorID,
&clip.LikesCount,
&clip.CommentsCount,
&clip.CreatedAt,
&clip.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrClipNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get clip: %w", err)
}
return clip, nil
}
func (r *clipRepository) GetByAuthorID(ctx context.Context, authorID, limit, offset int) ([]*domain.Clip, int, error) {
// Получаем общее количество
countQuery := `SELECT COUNT(*) FROM clips WHERE author_id = $1 AND deleted_at IS NULL`
var totalCount int
err := r.db.QueryRowContext(ctx, countQuery, authorID).Scan(&totalCount)
if err != nil {
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
}
// Получаем клипы
query := `
SELECT id, title, video_url, thumbnail_url, duration, author_id,
likes_count, comments_count, created_at, updated_at
FROM clips
WHERE author_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.QueryContext(ctx, query, authorID, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to get clips: %w", err)
}
defer rows.Close()
var clips []*domain.Clip
for rows.Next() {
clip := &domain.Clip{}
err := rows.Scan(
&clip.ID,
&clip.Title,
&clip.VideoURL,
&clip.ThumbnailURL,
&clip.Duration,
&clip.AuthorID,
&clip.LikesCount,
&clip.CommentsCount,
&clip.CreatedAt,
&clip.UpdatedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan clip: %w", err)
}
clips = append(clips, clip)
}
if err = rows.Err(); err != nil {
return nil, 0, fmt.Errorf("rows error: %w", err)
}
return clips, totalCount, nil
}
func (r *clipRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Clip, int, error) {
// Получаем общее количество
countQuery := `SELECT COUNT(*) FROM clips WHERE deleted_at IS NULL`
var totalCount int
err := r.db.QueryRowContext(ctx, countQuery).Scan(&totalCount)
if err != nil {
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
}
// Получаем клипы
query := `
SELECT id, title, video_url, thumbnail_url, duration, author_id,
likes_count, comments_count, created_at, updated_at
FROM clips
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`
rows, err := r.db.QueryContext(ctx, query, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to get clips: %w", err)
}
defer rows.Close()
var clips []*domain.Clip
for rows.Next() {
clip := &domain.Clip{}
err := rows.Scan(
&clip.ID,
&clip.Title,
&clip.VideoURL,
&clip.ThumbnailURL,
&clip.Duration,
&clip.AuthorID,
&clip.LikesCount,
&clip.CommentsCount,
&clip.CreatedAt,
&clip.UpdatedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan clip: %w", err)
}
clips = append(clips, clip)
}
if err = rows.Err(); err != nil {
return nil, 0, fmt.Errorf("rows error: %w", err)
}
return clips, totalCount, nil
}
func (r *clipRepository) Delete(ctx context.Context, id int) error {
query := `
UPDATE clips
SET deleted_at = $1
WHERE id = $2 AND deleted_at IS NULL
`
result, err := r.db.ExecContext(ctx, query, time.Now(), id)
if err != nil {
return fmt.Errorf("failed to delete clip: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return ErrClipNotFound
}
return nil
}
func (r *clipRepository) IncrementLikesCount(ctx context.Context, clipID int) error {
query := `
UPDATE clips
SET likes_count = likes_count + 1, updated_at = $1
WHERE id = $2 AND deleted_at IS NULL
`
_, err := r.db.ExecContext(ctx, query, time.Now(), clipID)
if err != nil {
return fmt.Errorf("failed to increment likes count: %w", err)
}
return nil
}
func (r *clipRepository) DecrementLikesCount(ctx context.Context, clipID int) error {
query := `
UPDATE clips
SET likes_count = GREATEST(likes_count - 1, 0), updated_at = $1
WHERE id = $2 AND deleted_at IS NULL
`
_, err := r.db.ExecContext(ctx, query, time.Now(), clipID)
if err != nil {
return fmt.Errorf("failed to decrement likes count: %w", err)
}
return nil
}
func (r *clipRepository) IncrementCommentsCount(ctx context.Context, clipID int) error {
query := `
UPDATE clips
SET comments_count = comments_count + 1, updated_at = $1
WHERE id = $2 AND deleted_at IS NULL
`
_, err := r.db.ExecContext(ctx, query, time.Now(), clipID)
if err != nil {
return fmt.Errorf("failed to increment comments count: %w", err)
}
return nil
}
func (r *clipRepository) DecrementCommentsCount(ctx context.Context, clipID int) error {
query := `
UPDATE clips
SET comments_count = GREATEST(comments_count - 1, 0), updated_at = $1
WHERE id = $2 AND deleted_at IS NULL
`
_, err := r.db.ExecContext(ctx, query, time.Now(), clipID)
if err != nil {
return fmt.Errorf("failed to decrement comments count: %w", err)
}
return nil
}
func (r *clipRepository) GetClipURLs(ctx context.Context, clipID int) (string, string, error) {
query := `
SELECT video_url, thumbnail_url
FROM clips
WHERE id = $1 AND deleted_at IS NULL
`
var videoURL, thumbnailURL string
err := r.db.QueryRowContext(ctx, query, clipID).Scan(&videoURL, &thumbnailURL)
if err == sql.ErrNoRows {
return "", "", ErrClipNotFound
}
if err != nil {
return "", "", fmt.Errorf("failed to get clip URLs: %w", err)
}
return videoURL, thumbnailURL, nil
}
// GetClipWithURLs возвращает клип с URL
func (r *clipRepository) GetClipWithURLs(ctx context.Context, clipID int) (*domain.Clip, error) {
query := `
SELECT id, title, video_url, thumbnail_url, duration, author_id,
likes_count, comments_count, created_at, updated_at
FROM clips
WHERE id = $1 AND deleted_at IS NULL
`
clip := &domain.Clip{}
err := r.db.QueryRowContext(ctx, query, clipID).Scan(
&clip.ID,
&clip.Title,
&clip.VideoURL,
&clip.ThumbnailURL,
&clip.Duration,
&clip.AuthorID,
&clip.LikesCount,
&clip.CommentsCount,
&clip.CreatedAt,
&clip.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrClipNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get clip: %w", err)
}
return clip, nil
}

View File

@ -0,0 +1,262 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"tailly_clips/internal/domain"
"time"
)
var (
ErrCommentNotFound = errors.New("comment not found")
)
type CommentRepository interface {
Create(ctx context.Context, comment *domain.ClipComment) error
GetByID(ctx context.Context, id int) (*domain.ClipComment, error)
GetByClipID(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipComment, int, error)
GetByAuthorID(ctx context.Context, authorID, limit, offset int) ([]*domain.ClipComment, int, error)
Update(ctx context.Context, comment *domain.ClipComment) error
Delete(ctx context.Context, id int) error
DeleteByClipID(ctx context.Context, clipID int) error
DeleteByAuthorID(ctx context.Context, authorID int) error
}
type commentRepository struct {
db *sql.DB
}
func NewCommentRepository(db *sql.DB) CommentRepository {
return &commentRepository{db: db}
}
func (r *commentRepository) Create(ctx context.Context, comment *domain.ClipComment) error {
query := `
INSERT INTO clip_comments (clip_id, author_id, content, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`
err := r.db.QueryRowContext(ctx, query,
comment.ClipID,
comment.AuthorID,
comment.Content,
comment.CreatedAt,
comment.UpdatedAt,
).Scan(&comment.ID)
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
return nil
}
func (r *commentRepository) GetByID(ctx context.Context, id int) (*domain.ClipComment, error) {
query := `
SELECT id, clip_id, author_id, content, created_at, updated_at
FROM clip_comments
WHERE id = $1 AND deleted_at IS NULL
`
comment := &domain.ClipComment{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&comment.ID,
&comment.ClipID,
&comment.AuthorID,
&comment.Content,
&comment.CreatedAt,
&comment.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrCommentNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get comment: %w", err)
}
return comment, nil
}
func (r *commentRepository) GetByClipID(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipComment, int, error) {
// Получаем общее количество
countQuery := `SELECT COUNT(*) FROM clip_comments WHERE clip_id = $1 AND deleted_at IS NULL`
var totalCount int
err := r.db.QueryRowContext(ctx, countQuery, clipID).Scan(&totalCount)
if err != nil {
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
}
// Получаем комментарии
query := `
SELECT id, clip_id, author_id, content, created_at, updated_at
FROM clip_comments
WHERE clip_id = $1 AND deleted_at IS NULL
ORDER BY created_at ASC
LIMIT $2 OFFSET $3
`
rows, err := r.db.QueryContext(ctx, query, clipID, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to get comments: %w", err)
}
defer rows.Close()
var comments []*domain.ClipComment
for rows.Next() {
comment := &domain.ClipComment{}
err := rows.Scan(
&comment.ID,
&comment.ClipID,
&comment.AuthorID,
&comment.Content,
&comment.CreatedAt,
&comment.UpdatedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan comment: %w", err)
}
comments = append(comments, comment)
}
if err = rows.Err(); err != nil {
return nil, 0, fmt.Errorf("rows error: %w", err)
}
return comments, totalCount, nil
}
func (r *commentRepository) GetByAuthorID(ctx context.Context, authorID, limit, offset int) ([]*domain.ClipComment, int, error) {
// Получаем общее количество
countQuery := `SELECT COUNT(*) FROM clip_comments WHERE author_id = $1 AND deleted_at IS NULL`
var totalCount int
err := r.db.QueryRowContext(ctx, countQuery, authorID).Scan(&totalCount)
if err != nil {
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
}
// Получаем комментарии
query := `
SELECT id, clip_id, author_id, content, created_at, updated_at
FROM clip_comments
WHERE author_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.QueryContext(ctx, query, authorID, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to get comments: %w", err)
}
defer rows.Close()
var comments []*domain.ClipComment
for rows.Next() {
comment := &domain.ClipComment{}
err := rows.Scan(
&comment.ID,
&comment.ClipID,
&comment.AuthorID,
&comment.Content,
&comment.CreatedAt,
&comment.UpdatedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan comment: %w", err)
}
comments = append(comments, comment)
}
if err = rows.Err(); err != nil {
return nil, 0, fmt.Errorf("rows error: %w", err)
}
return comments, totalCount, nil
}
func (r *commentRepository) Update(ctx context.Context, comment *domain.ClipComment) error {
query := `
UPDATE clip_comments
SET content = $1, updated_at = $2
WHERE id = $3 AND deleted_at IS NULL
`
result, err := r.db.ExecContext(ctx, query,
comment.Content,
time.Now(),
comment.ID,
)
if err != nil {
return fmt.Errorf("failed to update comment: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return ErrCommentNotFound
}
comment.UpdatedAt = time.Now()
return nil
}
func (r *commentRepository) Delete(ctx context.Context, id int) error {
query := `
UPDATE clip_comments
SET deleted_at = $1
WHERE id = $2 AND deleted_at IS NULL
`
result, err := r.db.ExecContext(ctx, query, time.Now(), id)
if err != nil {
return fmt.Errorf("failed to delete comment: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return ErrCommentNotFound
}
return nil
}
func (r *commentRepository) DeleteByClipID(ctx context.Context, clipID int) error {
query := `
UPDATE clip_comments
SET deleted_at = $1
WHERE clip_id = $2 AND deleted_at IS NULL
`
_, err := r.db.ExecContext(ctx, query, time.Now(), clipID)
if err != nil {
return fmt.Errorf("failed to delete comments by clip ID: %w", err)
}
return nil
}
func (r *commentRepository) DeleteByAuthorID(ctx context.Context, authorID int) error {
query := `
UPDATE clip_comments
SET deleted_at = $1
WHERE author_id = $2 AND deleted_at IS NULL
`
_, err := r.db.ExecContext(ctx, query, time.Now(), authorID)
if err != nil {
return fmt.Errorf("failed to delete comments by author ID: %w", err)
}
return nil
}

View File

@ -0,0 +1,242 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"tailly_clips/internal/domain"
)
var (
ErrLikeNotFound = errors.New("like not found")
ErrLikeAlreadyExists = errors.New("like already exists")
)
type LikeRepository interface {
Create(ctx context.Context, like *domain.ClipLike) error
GetByID(ctx context.Context, id int) (*domain.ClipLike, error)
GetByClipID(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipLike, int, error)
GetByUserID(ctx context.Context, userID, limit, offset int) ([]*domain.ClipLike, int, error)
CheckExists(ctx context.Context, clipID, userID int) (bool, error)
Delete(ctx context.Context, clipID, userID int) error
DeleteByClipID(ctx context.Context, clipID int) error
DeleteByUserID(ctx context.Context, userID int) error
}
type likeRepository struct {
db *sql.DB
}
func NewLikeRepository(db *sql.DB) LikeRepository {
return &likeRepository{db: db}
}
func (r *likeRepository) Create(ctx context.Context, like *domain.ClipLike) error {
query := `
INSERT INTO clip_likes (clip_id, user_id, created_at)
VALUES ($1, $2, $3)
RETURNING id
`
err := r.db.QueryRowContext(ctx, query,
like.ClipID,
like.UserID,
like.CreatedAt,
).Scan(&like.ID)
if err != nil {
if isDuplicateKeyError(err) {
return ErrLikeAlreadyExists
}
return fmt.Errorf("failed to create like: %w", err)
}
return nil
}
func (r *likeRepository) GetByID(ctx context.Context, id int) (*domain.ClipLike, error) {
query := `
SELECT id, clip_id, user_id, created_at
FROM clip_likes
WHERE id = $1
`
like := &domain.ClipLike{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&like.ID,
&like.ClipID,
&like.UserID,
&like.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrLikeNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get like: %w", err)
}
return like, nil
}
func (r *likeRepository) GetByClipID(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipLike, int, error) {
// Получаем общее количество
countQuery := `SELECT COUNT(*) FROM clip_likes WHERE clip_id = $1`
var totalCount int
err := r.db.QueryRowContext(ctx, countQuery, clipID).Scan(&totalCount)
if err != nil {
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
}
// Получаем лайки
query := `
SELECT id, clip_id, user_id, created_at
FROM clip_likes
WHERE clip_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.QueryContext(ctx, query, clipID, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to get likes: %w", err)
}
defer rows.Close()
var likes []*domain.ClipLike
for rows.Next() {
like := &domain.ClipLike{}
err := rows.Scan(
&like.ID,
&like.ClipID,
&like.UserID,
&like.CreatedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan like: %w", err)
}
likes = append(likes, like)
}
if err = rows.Err(); err != nil {
return nil, 0, fmt.Errorf("rows error: %w", err)
}
return likes, totalCount, nil
}
func (r *likeRepository) GetByUserID(ctx context.Context, userID, limit, offset int) ([]*domain.ClipLike, int, error) {
// Получаем общее количество
countQuery := `SELECT COUNT(*) FROM clip_likes WHERE user_id = $1`
var totalCount int
err := r.db.QueryRowContext(ctx, countQuery, userID).Scan(&totalCount)
if err != nil {
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
}
// Получаем лайки
query := `
SELECT id, clip_id, user_id, created_at
FROM clip_likes
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
rows, err := r.db.QueryContext(ctx, query, userID, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to get likes: %w", err)
}
defer rows.Close()
var likes []*domain.ClipLike
for rows.Next() {
like := &domain.ClipLike{}
err := rows.Scan(
&like.ID,
&like.ClipID,
&like.UserID,
&like.CreatedAt,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to scan like: %w", err)
}
likes = append(likes, like)
}
if err = rows.Err(); err != nil {
return nil, 0, fmt.Errorf("rows error: %w", err)
}
return likes, totalCount, nil
}
func (r *likeRepository) CheckExists(ctx context.Context, clipID, userID int) (bool, error) {
query := `
SELECT EXISTS(
SELECT 1 FROM clip_likes
WHERE clip_id = $1 AND user_id = $2
)
`
var exists bool
err := r.db.QueryRowContext(ctx, query, clipID, userID).Scan(&exists)
if err != nil {
return false, fmt.Errorf("failed to check like existence: %w", err)
}
return exists, nil
}
func (r *likeRepository) Delete(ctx context.Context, clipID, userID int) error {
query := `
DELETE FROM clip_likes
WHERE clip_id = $1 AND user_id = $2
`
result, err := r.db.ExecContext(ctx, query, clipID, userID)
if err != nil {
return fmt.Errorf("failed to delete like: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)
}
if rowsAffected == 0 {
return ErrLikeNotFound
}
return nil
}
func (r *likeRepository) DeleteByClipID(ctx context.Context, clipID int) error {
query := `DELETE FROM clip_likes WHERE clip_id = $1`
_, err := r.db.ExecContext(ctx, query, clipID)
if err != nil {
return fmt.Errorf("failed to delete likes by clip ID: %w", err)
}
return nil
}
func (r *likeRepository) DeleteByUserID(ctx context.Context, userID int) error {
query := `DELETE FROM clip_likes WHERE user_id = $1`
_, err := r.db.ExecContext(ctx, query, userID)
if err != nil {
return fmt.Errorf("failed to delete likes by user ID: %w", err)
}
return nil
}
func isDuplicateKeyError(err error) bool {
// Проверка на дубликат ключа (зависит от драйвера БД)
// Для PostgreSQL обычно содержит "duplicate key"
return err != nil && (err.Error() == "pq: duplicate key value violates unique constraint" ||
err.Error() == "ERROR: duplicate key value violates unique constraint")
}

View File

@ -0,0 +1,217 @@
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
}

View File

@ -0,0 +1,103 @@
package service
import (
"context"
"fmt"
"tailly_clips/internal/domain"
"tailly_clips/internal/repository"
"time"
)
type CommentService interface {
CreateComment(ctx context.Context, clipID, userID int, content string) (*domain.ClipComment, error)
GetClipComments(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipComment, int, error)
DeleteComment(ctx context.Context, commentID, userID int) error
}
type commentService struct {
commentRepo repository.CommentRepository
clipRepo repository.ClipRepository
}
func NewCommentService(commentRepo repository.CommentRepository, clipRepo repository.ClipRepository) CommentService {
return &commentService{
commentRepo: commentRepo,
clipRepo: clipRepo,
}
}
func (s *commentService) CreateComment(ctx context.Context, clipID, userID int, content string) (*domain.ClipComment, error) {
// Проверяем существование клипа
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
return nil, fmt.Errorf("clip not found: %w", err)
}
// Валидация контента
if content == "" {
return nil, fmt.Errorf("comment content cannot be empty")
}
if len(content) > 1000 {
return nil, fmt.Errorf("comment content too long")
}
// Создаем комментарий
comment := &domain.ClipComment{
ClipID: clipID,
AuthorID: userID,
Content: content,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.commentRepo.Create(ctx, comment); err != nil {
return nil, fmt.Errorf("failed to create comment: %w", err)
}
// Увеличиваем счетчик комментариев
if err := s.clipRepo.IncrementCommentsCount(ctx, clipID); err != nil {
// Откатываем создание комментария при ошибке
s.commentRepo.Delete(ctx, comment.ID)
return nil, fmt.Errorf("failed to increment comments count: %w", err)
}
return comment, nil
}
func (s *commentService) GetClipComments(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipComment, int, error) {
// Проверяем существование клипа
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
return nil, 0, fmt.Errorf("clip not found: %w", err)
}
comments, totalCount, err := s.commentRepo.GetByClipID(ctx, clipID, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to get clip comments: %w", err)
}
return comments, totalCount, nil
}
func (s *commentService) DeleteComment(ctx context.Context, commentID, userID int) error {
// Получаем комментарий для проверки прав
comment, err := s.commentRepo.GetByID(ctx, commentID)
if err != nil {
return fmt.Errorf("failed to get comment: %w", err)
}
// Проверяем права доступа
if comment.AuthorID != userID {
return fmt.Errorf("user %d is not the author of comment %d", userID, commentID)
}
// Удаляем комментарий
if err := s.commentRepo.Delete(ctx, commentID); err != nil {
return fmt.Errorf("failed to delete comment: %w", err)
}
// Уменьшаем счетчик комментариев
if err := s.clipRepo.DecrementCommentsCount(ctx, comment.ClipID); err != nil {
return fmt.Errorf("failed to decrement comments count: %w", err)
}
return nil
}

View File

@ -0,0 +1,111 @@
package service
import (
"context"
"fmt"
"tailly_clips/internal/domain"
"tailly_clips/internal/repository"
"time"
)
type LikeService interface {
LikeClip(ctx context.Context, clipID, userID int) (*domain.ClipLike, error)
UnlikeClip(ctx context.Context, clipID, userID int) error
GetClipLikes(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipLike, int, error)
CheckIfLiked(ctx context.Context, clipID, userID int) (bool, error)
}
type likeService struct {
likeRepo repository.LikeRepository
clipRepo repository.ClipRepository
}
func NewLikeService(likeRepo repository.LikeRepository, clipRepo repository.ClipRepository) LikeService {
return &likeService{
likeRepo: likeRepo,
clipRepo: clipRepo,
}
}
func (s *likeService) LikeClip(ctx context.Context, clipID, userID int) (*domain.ClipLike, error) {
// Проверяем существование клипа
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
return nil, fmt.Errorf("clip not found: %w", err)
}
// Проверяем не лайкнул ли уже пользователь
alreadyLiked, err := s.likeRepo.CheckExists(ctx, clipID, userID)
if err != nil {
return nil, fmt.Errorf("failed to check like existence: %w", err)
}
if alreadyLiked {
return nil, fmt.Errorf("user already liked this clip")
}
// Создаем лайк
like := &domain.ClipLike{
ClipID: clipID,
UserID: userID,
CreatedAt: time.Now(),
}
if err := s.likeRepo.Create(ctx, like); err != nil {
return nil, fmt.Errorf("failed to create like: %w", err)
}
// Увеличиваем счетчик лайков
if err := s.clipRepo.IncrementLikesCount(ctx, clipID); err != nil {
// Откатываем создание лайка при ошибке
s.likeRepo.Delete(ctx, clipID, userID)
return nil, fmt.Errorf("failed to increment likes count: %w", err)
}
return like, nil
}
func (s *likeService) UnlikeClip(ctx context.Context, clipID, userID int) error {
// Проверяем существование клипа
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
return fmt.Errorf("clip not found: %w", err)
}
// Удаляем лайк
if err := s.likeRepo.Delete(ctx, clipID, userID); err != nil {
return fmt.Errorf("failed to delete like: %w", err)
}
// Уменьшаем счетчик лайков
if err := s.clipRepo.DecrementLikesCount(ctx, clipID); err != nil {
return fmt.Errorf("failed to decrement likes count: %w", err)
}
return nil
}
func (s *likeService) GetClipLikes(ctx context.Context, clipID, limit, offset int) ([]*domain.ClipLike, int, error) {
// Проверяем существование клипа
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
return nil, 0, fmt.Errorf("clip not found: %w", err)
}
likes, totalCount, err := s.likeRepo.GetByClipID(ctx, clipID, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to get clip likes: %w", err)
}
return likes, totalCount, nil
}
func (s *likeService) CheckIfLiked(ctx context.Context, clipID, userID int) (bool, error) {
// Проверяем существование клипа
if _, err := s.clipRepo.GetByID(ctx, clipID); err != nil {
return false, fmt.Errorf("clip not found: %w", err)
}
isLiked, err := s.likeRepo.CheckExists(ctx, clipID, userID)
if err != nil {
return false, fmt.Errorf("failed to check like status: %w", err)
}
return isLiked, nil
}

View File

@ -0,0 +1,240 @@
package storage
import (
"bytes"
"context"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
)
type Storage interface {
UploadVideo(ctx context.Context, videoData []byte, fileName string, userID int) (string, error)
UploadThumbnail(ctx context.Context, thumbnailData []byte, userID int) (string, error)
Delete(ctx context.Context, fileURL string) error
DeleteVideo(ctx context.Context, videoURL string) error
DeleteThumbnail(ctx context.Context, thumbnailURL string) error
BulkDelete(ctx context.Context, fileURLs []string) error
CheckObjectExists(ctx context.Context, fileURL string) (bool, error)
}
type S3Config struct {
Endpoint string
Bucket string
Region string
AccessKey string
SecretKey string
}
type s3Storage struct {
uploader *s3manager.Uploader
s3Client *s3.S3
bucket string
region string
endpoint string
}
// NewS3Storage создает S3 storage - теперь требует конфигурацию
func NewS3Storage(config S3Config) Storage {
sess := session.Must(session.NewSession(&aws.Config{
Region: aws.String(config.Region),
Endpoint: aws.String(config.Endpoint),
S3ForcePathStyle: aws.Bool(true),
Credentials: credentials.NewStaticCredentials(
config.AccessKey,
config.SecretKey,
"",
),
}))
uploader := s3manager.NewUploader(sess)
s3Client := s3.New(sess)
return &s3Storage{
uploader: uploader,
s3Client: s3Client,
bucket: config.Bucket,
region: config.Region,
endpoint: config.Endpoint,
}
}
func (s *s3Storage) UploadVideo(ctx context.Context, videoData []byte, fileName string, userID int) (string, error) {
ext := strings.ToLower(filepath.Ext(fileName))
timestamp := time.Now().UnixNano()
s3Key := fmt.Sprintf("clips/%d/videos/%d%s", userID, timestamp, ext)
_, err := s.uploader.UploadWithContext(ctx, &s3manager.UploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
Body: bytes.NewReader(videoData),
ContentType: aws.String(s.getVideoContentType(ext)),
})
if err != nil {
return "", fmt.Errorf("failed to upload video to S3: %w", err)
}
return fmt.Sprintf("%s/%s/%s", s.endpoint, s.bucket, s3Key), nil
}
func (s *s3Storage) UploadThumbnail(ctx context.Context, thumbnailData []byte, userID int) (string, error) {
timestamp := time.Now().UnixNano()
s3Key := fmt.Sprintf("clips/%d/thumbnails/%d.jpg", userID, timestamp)
_, err := s.uploader.Upload(&s3manager.UploadInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
Body: aws.ReadSeekCloser(strings.NewReader(string(thumbnailData))),
ContentType: aws.String("image/jpeg"),
})
if err != nil {
return "", fmt.Errorf("failed to upload thumbnail to S3: %w", err)
}
return fmt.Sprintf("%s/%s/%s", s.endpoint, s.bucket, s3Key), nil
}
func (s *s3Storage) Delete(ctx context.Context, fileURL string) error {
s3Key, err := s.extractS3Key(fileURL)
if err != nil {
return err
}
return s.deleteObject(s3Key)
}
func (s *s3Storage) DeleteVideo(ctx context.Context, videoURL string) error {
s3Key, err := s.extractS3Key(videoURL)
if err != nil {
return err
}
return s.deleteObject(s3Key)
}
func (s *s3Storage) DeleteThumbnail(ctx context.Context, thumbnailURL string) error {
s3Key, err := s.extractS3Key(thumbnailURL)
if err != nil {
return err
}
return s.deleteObject(s3Key)
}
func (s *s3Storage) deleteObject(s3Key string) error {
_, err := s.s3Client.DeleteObject(&s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
if err != nil {
return fmt.Errorf("failed to delete object from S3: %w", err)
}
// Ждем пока объект действительно удалится
err = s.s3Client.WaitUntilObjectNotExists(&s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
if err != nil {
return fmt.Errorf("failed to wait for object deletion: %w", err)
}
return nil
}
func (s *s3Storage) extractS3Key(fileURL string) (string, error) {
// Убираем протокол и endpoint
cleanURL := strings.TrimPrefix(fileURL, s.endpoint+"/")
// Убираем bucket name
parts := strings.SplitN(cleanURL, "/", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid S3 URL format: %s", fileURL)
}
if parts[0] != s.bucket {
return "", fmt.Errorf("invalid bucket in URL: expected %s, got %s", s.bucket, parts[0])
}
return parts[1], nil
}
func (s *s3Storage) getVideoContentType(ext string) string {
switch ext {
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".mov":
return "video/quicktime"
case ".avi":
return "video/x-msvideo"
case ".mkv":
return "video/x-matroska"
case ".flv":
return "video/x-flv"
case ".wmv":
return "video/x-ms-wmv"
default:
return "video/mp4"
}
}
// BulkDelete удаляет несколько файлов
func (s *s3Storage) BulkDelete(ctx context.Context, fileURLs []string) error {
if len(fileURLs) == 0 {
return nil
}
var objects []*s3.ObjectIdentifier
for _, url := range fileURLs {
s3Key, err := s.extractS3Key(url)
if err != nil {
return err
}
objects = append(objects, &s3.ObjectIdentifier{
Key: aws.String(s3Key),
})
}
_, err := s.s3Client.DeleteObjects(&s3.DeleteObjectsInput{
Bucket: aws.String(s.bucket),
Delete: &s3.Delete{
Objects: objects,
Quiet: aws.Bool(false),
},
})
if err != nil {
return fmt.Errorf("failed to delete objects from S3: %w", err)
}
return nil
}
// CheckObjectExists проверяет существует ли объект в S3
func (s *s3Storage) CheckObjectExists(ctx context.Context, fileURL string) (bool, error) {
s3Key, err := s.extractS3Key(fileURL)
if err != nil {
return false, err
}
_, err = s.s3Client.HeadObject(&s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s3Key),
})
if err != nil {
if strings.Contains(err.Error(), "NotFound") {
return false, nil
}
return false, fmt.Errorf("failed to check object existence: %w", err)
}
return true, nil
}

View File

@ -0,0 +1,16 @@
package utils
import (
"strings"
)
func IsDuplicateKeyError(err error) bool {
if err == nil {
return false
}
// Для PostgreSQL
return strings.Contains(err.Error(), "duplicate key") ||
strings.Contains(err.Error(), "violates unique constraint")
}

View File

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS clips CASCADE;
DROP TABLE IF EXISTS clip_likes CASCADE;
DROP TABLE IF EXISTS clip_comments CASCADE;

View File

@ -0,0 +1,43 @@
-- Клипы
CREATE TABLE clips (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
video_url TEXT NOT NULL,
thumbnail_url TEXT NOT NULL,
duration INT NOT NULL,
author_id INTEGER NOT NULL,
likes_count INTEGER DEFAULT 0,
comments_count INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE
);
-- Лайки клипов
CREATE TABLE clip_likes (
id SERIAL PRIMARY KEY,
clip_id INTEGER NOT NULL REFERENCES clips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(clip_id, user_id)
);
-- Комментарии к клипам
CREATE TABLE clip_comments (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
clip_id INTEGER NOT NULL REFERENCES clips(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE
);
-- Индексы для производительности
CREATE INDEX idx_clips_author_id ON clips(author_id);
CREATE INDEX idx_clip_likes_clip_id ON clip_likes(clip_id);
CREATE INDEX idx_clip_likes_user_id ON clip_likes(user_id);
CREATE INDEX idx_clip_comments_clip_id ON clip_comments(clip_id);
CREATE INDEX idx_clip_comments_author_id ON clip_comments(author_id);
CREATE INDEX idx_clips_deleted_at ON clips(deleted_at);
CREATE INDEX idx_clip_comments_deleted_at ON clip_comments(deleted_at);

1625
proto/clip.pb.go Normal file

File diff suppressed because it is too large Load Diff

163
proto/clip.proto Normal file
View File

@ -0,0 +1,163 @@
syntax = "proto3";
package proto;
option go_package = ".;proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
service ClipService {
// Клипы
rpc CreateClip(CreateClipRequest) returns (CreateClipResponse);
rpc GetClip(GetClipRequest) returns (GetClipResponse);
rpc GetUserClips(GetUserClipsRequest) returns (GetUserClipsResponse);
rpc GetClips(GetClipsRequest) returns (GetClipsResponse);
rpc DeleteClip(DeleteClipRequest) returns (google.protobuf.Empty);
// Лайки
rpc LikeClip(LikeClipRequest) returns (LikeClipResponse);
rpc UnlikeClip(UnlikeClipRequest) returns (google.protobuf.Empty);
rpc GetClipLikes(GetClipLikesRequest) returns (GetClipLikesResponse);
rpc CheckIfLiked(CheckIfLikedRequest) returns (CheckIfLikedResponse);
// Комментарии
rpc CreateComment(CreateCommentRequest) returns (CreateCommentResponse);
rpc GetClipComments(GetClipCommentsRequest) returns (GetClipCommentsResponse);
rpc DeleteComment(DeleteCommentRequest) returns (google.protobuf.Empty);
}
message CreateClipRequest {
int32 user_id = 1;
string title = 2;
bytes video_data = 3;
string file_name = 4;
string content_type = 5;
}
message CreateClipResponse {
Clip clip = 1;
}
message GetClipRequest {
int32 clip_id = 1;
}
message GetClipResponse {
Clip clip = 1;
}
message GetUserClipsRequest {
int32 user_id = 1;
int32 limit = 2;
int32 offset = 3;
}
message GetUserClipsResponse {
repeated Clip clips = 1;
int32 total_count = 2;
}
message GetClipsRequest {
int32 limit = 1;
int32 offset = 2;
}
message GetClipsResponse {
repeated Clip clips = 1;
int32 total_count = 2;
}
message DeleteClipRequest {
int32 clip_id = 1;
int32 user_id = 2;
}
message LikeClipRequest {
int32 clip_id = 1;
int32 user_id = 2;
}
message LikeClipResponse {
ClipLike like = 1;
}
message UnlikeClipRequest {
int32 clip_id = 1;
int32 user_id = 2;
}
message GetClipLikesRequest {
int32 clip_id = 1;
int32 limit = 2;
int32 offset = 3;
}
message GetClipLikesResponse {
repeated ClipLike likes = 1;
int32 total_count = 2;
}
message CheckIfLikedRequest {
int32 clip_id = 1;
int32 user_id = 2;
}
message CheckIfLikedResponse {
bool is_liked = 1;
}
message CreateCommentRequest {
int32 clip_id = 1;
int32 user_id = 2;
string content = 3;
}
message CreateCommentResponse {
ClipComment comment = 1;
}
message GetClipCommentsRequest {
int32 clip_id = 1;
int32 limit = 2;
int32 offset = 3;
}
message GetClipCommentsResponse {
repeated ClipComment comments = 1;
int32 total_count = 2;
}
message DeleteCommentRequest {
int32 comment_id = 1;
int32 user_id = 2;
}
message Clip {
int32 id = 1;
string title = 2;
string video_url = 3;
string thumbnail_url = 4;
int32 duration = 5;
int32 author_id = 6;
int32 likes_count = 7;
int32 comments_count = 8;
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp updated_at = 10;
}
message ClipLike {
int32 id = 1;
int32 clip_id = 2;
int32 user_id = 3;
google.protobuf.Timestamp created_at = 4;
}
message ClipComment {
int32 id = 1;
int32 clip_id = 2;
int32 author_id = 3;
string content = 4;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp updated_at = 6;
}

546
proto/clip_grpc.pb.go Normal file
View File

@ -0,0 +1,546 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.21.12
// source: clip.proto
package proto
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
emptypb "google.golang.org/protobuf/types/known/emptypb"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
ClipService_CreateClip_FullMethodName = "/proto.ClipService/CreateClip"
ClipService_GetClip_FullMethodName = "/proto.ClipService/GetClip"
ClipService_GetUserClips_FullMethodName = "/proto.ClipService/GetUserClips"
ClipService_GetClips_FullMethodName = "/proto.ClipService/GetClips"
ClipService_DeleteClip_FullMethodName = "/proto.ClipService/DeleteClip"
ClipService_LikeClip_FullMethodName = "/proto.ClipService/LikeClip"
ClipService_UnlikeClip_FullMethodName = "/proto.ClipService/UnlikeClip"
ClipService_GetClipLikes_FullMethodName = "/proto.ClipService/GetClipLikes"
ClipService_CheckIfLiked_FullMethodName = "/proto.ClipService/CheckIfLiked"
ClipService_CreateComment_FullMethodName = "/proto.ClipService/CreateComment"
ClipService_GetClipComments_FullMethodName = "/proto.ClipService/GetClipComments"
ClipService_DeleteComment_FullMethodName = "/proto.ClipService/DeleteComment"
)
// ClipServiceClient is the client API for ClipService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type ClipServiceClient interface {
// Клипы
CreateClip(ctx context.Context, in *CreateClipRequest, opts ...grpc.CallOption) (*CreateClipResponse, error)
GetClip(ctx context.Context, in *GetClipRequest, opts ...grpc.CallOption) (*GetClipResponse, error)
GetUserClips(ctx context.Context, in *GetUserClipsRequest, opts ...grpc.CallOption) (*GetUserClipsResponse, error)
GetClips(ctx context.Context, in *GetClipsRequest, opts ...grpc.CallOption) (*GetClipsResponse, error)
DeleteClip(ctx context.Context, in *DeleteClipRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// Лайки
LikeClip(ctx context.Context, in *LikeClipRequest, opts ...grpc.CallOption) (*LikeClipResponse, error)
UnlikeClip(ctx context.Context, in *UnlikeClipRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
GetClipLikes(ctx context.Context, in *GetClipLikesRequest, opts ...grpc.CallOption) (*GetClipLikesResponse, error)
CheckIfLiked(ctx context.Context, in *CheckIfLikedRequest, opts ...grpc.CallOption) (*CheckIfLikedResponse, error)
// Комментарии
CreateComment(ctx context.Context, in *CreateCommentRequest, opts ...grpc.CallOption) (*CreateCommentResponse, error)
GetClipComments(ctx context.Context, in *GetClipCommentsRequest, opts ...grpc.CallOption) (*GetClipCommentsResponse, error)
DeleteComment(ctx context.Context, in *DeleteCommentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
}
type clipServiceClient struct {
cc grpc.ClientConnInterface
}
func NewClipServiceClient(cc grpc.ClientConnInterface) ClipServiceClient {
return &clipServiceClient{cc}
}
func (c *clipServiceClient) CreateClip(ctx context.Context, in *CreateClipRequest, opts ...grpc.CallOption) (*CreateClipResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateClipResponse)
err := c.cc.Invoke(ctx, ClipService_CreateClip_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) GetClip(ctx context.Context, in *GetClipRequest, opts ...grpc.CallOption) (*GetClipResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetClipResponse)
err := c.cc.Invoke(ctx, ClipService_GetClip_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) GetUserClips(ctx context.Context, in *GetUserClipsRequest, opts ...grpc.CallOption) (*GetUserClipsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetUserClipsResponse)
err := c.cc.Invoke(ctx, ClipService_GetUserClips_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) GetClips(ctx context.Context, in *GetClipsRequest, opts ...grpc.CallOption) (*GetClipsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetClipsResponse)
err := c.cc.Invoke(ctx, ClipService_GetClips_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) DeleteClip(ctx context.Context, in *DeleteClipRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, ClipService_DeleteClip_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) LikeClip(ctx context.Context, in *LikeClipRequest, opts ...grpc.CallOption) (*LikeClipResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(LikeClipResponse)
err := c.cc.Invoke(ctx, ClipService_LikeClip_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) UnlikeClip(ctx context.Context, in *UnlikeClipRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, ClipService_UnlikeClip_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) GetClipLikes(ctx context.Context, in *GetClipLikesRequest, opts ...grpc.CallOption) (*GetClipLikesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetClipLikesResponse)
err := c.cc.Invoke(ctx, ClipService_GetClipLikes_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) CheckIfLiked(ctx context.Context, in *CheckIfLikedRequest, opts ...grpc.CallOption) (*CheckIfLikedResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckIfLikedResponse)
err := c.cc.Invoke(ctx, ClipService_CheckIfLiked_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) CreateComment(ctx context.Context, in *CreateCommentRequest, opts ...grpc.CallOption) (*CreateCommentResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateCommentResponse)
err := c.cc.Invoke(ctx, ClipService_CreateComment_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) GetClipComments(ctx context.Context, in *GetClipCommentsRequest, opts ...grpc.CallOption) (*GetClipCommentsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetClipCommentsResponse)
err := c.cc.Invoke(ctx, ClipService_GetClipComments_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *clipServiceClient) DeleteComment(ctx context.Context, in *DeleteCommentRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(emptypb.Empty)
err := c.cc.Invoke(ctx, ClipService_DeleteComment_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// ClipServiceServer is the server API for ClipService service.
// All implementations must embed UnimplementedClipServiceServer
// for forward compatibility.
type ClipServiceServer interface {
// Клипы
CreateClip(context.Context, *CreateClipRequest) (*CreateClipResponse, error)
GetClip(context.Context, *GetClipRequest) (*GetClipResponse, error)
GetUserClips(context.Context, *GetUserClipsRequest) (*GetUserClipsResponse, error)
GetClips(context.Context, *GetClipsRequest) (*GetClipsResponse, error)
DeleteClip(context.Context, *DeleteClipRequest) (*emptypb.Empty, error)
// Лайки
LikeClip(context.Context, *LikeClipRequest) (*LikeClipResponse, error)
UnlikeClip(context.Context, *UnlikeClipRequest) (*emptypb.Empty, error)
GetClipLikes(context.Context, *GetClipLikesRequest) (*GetClipLikesResponse, error)
CheckIfLiked(context.Context, *CheckIfLikedRequest) (*CheckIfLikedResponse, error)
// Комментарии
CreateComment(context.Context, *CreateCommentRequest) (*CreateCommentResponse, error)
GetClipComments(context.Context, *GetClipCommentsRequest) (*GetClipCommentsResponse, error)
DeleteComment(context.Context, *DeleteCommentRequest) (*emptypb.Empty, error)
mustEmbedUnimplementedClipServiceServer()
}
// UnimplementedClipServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedClipServiceServer struct{}
func (UnimplementedClipServiceServer) CreateClip(context.Context, *CreateClipRequest) (*CreateClipResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateClip not implemented")
}
func (UnimplementedClipServiceServer) GetClip(context.Context, *GetClipRequest) (*GetClipResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetClip not implemented")
}
func (UnimplementedClipServiceServer) GetUserClips(context.Context, *GetUserClipsRequest) (*GetUserClipsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUserClips not implemented")
}
func (UnimplementedClipServiceServer) GetClips(context.Context, *GetClipsRequest) (*GetClipsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetClips not implemented")
}
func (UnimplementedClipServiceServer) DeleteClip(context.Context, *DeleteClipRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteClip not implemented")
}
func (UnimplementedClipServiceServer) LikeClip(context.Context, *LikeClipRequest) (*LikeClipResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method LikeClip not implemented")
}
func (UnimplementedClipServiceServer) UnlikeClip(context.Context, *UnlikeClipRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method UnlikeClip not implemented")
}
func (UnimplementedClipServiceServer) GetClipLikes(context.Context, *GetClipLikesRequest) (*GetClipLikesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetClipLikes not implemented")
}
func (UnimplementedClipServiceServer) CheckIfLiked(context.Context, *CheckIfLikedRequest) (*CheckIfLikedResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckIfLiked not implemented")
}
func (UnimplementedClipServiceServer) CreateComment(context.Context, *CreateCommentRequest) (*CreateCommentResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateComment not implemented")
}
func (UnimplementedClipServiceServer) GetClipComments(context.Context, *GetClipCommentsRequest) (*GetClipCommentsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetClipComments not implemented")
}
func (UnimplementedClipServiceServer) DeleteComment(context.Context, *DeleteCommentRequest) (*emptypb.Empty, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteComment not implemented")
}
func (UnimplementedClipServiceServer) mustEmbedUnimplementedClipServiceServer() {}
func (UnimplementedClipServiceServer) testEmbeddedByValue() {}
// UnsafeClipServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to ClipServiceServer will
// result in compilation errors.
type UnsafeClipServiceServer interface {
mustEmbedUnimplementedClipServiceServer()
}
func RegisterClipServiceServer(s grpc.ServiceRegistrar, srv ClipServiceServer) {
// If the following call pancis, it indicates UnimplementedClipServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&ClipService_ServiceDesc, srv)
}
func _ClipService_CreateClip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateClipRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).CreateClip(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_CreateClip_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).CreateClip(ctx, req.(*CreateClipRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_GetClip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetClipRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).GetClip(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_GetClip_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).GetClip(ctx, req.(*GetClipRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_GetUserClips_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserClipsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).GetUserClips(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_GetUserClips_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).GetUserClips(ctx, req.(*GetUserClipsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_GetClips_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetClipsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).GetClips(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_GetClips_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).GetClips(ctx, req.(*GetClipsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_DeleteClip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteClipRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).DeleteClip(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_DeleteClip_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).DeleteClip(ctx, req.(*DeleteClipRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_LikeClip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LikeClipRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).LikeClip(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_LikeClip_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).LikeClip(ctx, req.(*LikeClipRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_UnlikeClip_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UnlikeClipRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).UnlikeClip(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_UnlikeClip_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).UnlikeClip(ctx, req.(*UnlikeClipRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_GetClipLikes_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetClipLikesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).GetClipLikes(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_GetClipLikes_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).GetClipLikes(ctx, req.(*GetClipLikesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_CheckIfLiked_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckIfLikedRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).CheckIfLiked(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_CheckIfLiked_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).CheckIfLiked(ctx, req.(*CheckIfLikedRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_CreateComment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateCommentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).CreateComment(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_CreateComment_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).CreateComment(ctx, req.(*CreateCommentRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_GetClipComments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetClipCommentsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).GetClipComments(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_GetClipComments_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).GetClipComments(ctx, req.(*GetClipCommentsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClipService_DeleteComment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteCommentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClipServiceServer).DeleteComment(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClipService_DeleteComment_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClipServiceServer).DeleteComment(ctx, req.(*DeleteCommentRequest))
}
return interceptor(ctx, in, info, handler)
}
// ClipService_ServiceDesc is the grpc.ServiceDesc for ClipService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var ClipService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "proto.ClipService",
HandlerType: (*ClipServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "CreateClip",
Handler: _ClipService_CreateClip_Handler,
},
{
MethodName: "GetClip",
Handler: _ClipService_GetClip_Handler,
},
{
MethodName: "GetUserClips",
Handler: _ClipService_GetUserClips_Handler,
},
{
MethodName: "GetClips",
Handler: _ClipService_GetClips_Handler,
},
{
MethodName: "DeleteClip",
Handler: _ClipService_DeleteClip_Handler,
},
{
MethodName: "LikeClip",
Handler: _ClipService_LikeClip_Handler,
},
{
MethodName: "UnlikeClip",
Handler: _ClipService_UnlikeClip_Handler,
},
{
MethodName: "GetClipLikes",
Handler: _ClipService_GetClipLikes_Handler,
},
{
MethodName: "CheckIfLiked",
Handler: _ClipService_CheckIfLiked_Handler,
},
{
MethodName: "CreateComment",
Handler: _ClipService_CreateComment_Handler,
},
{
MethodName: "GetClipComments",
Handler: _ClipService_GetClipComments_Handler,
},
{
MethodName: "DeleteComment",
Handler: _ClipService_DeleteComment_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "clip.proto",
}