mirror of
https://gitlab.com/foxixus/neomovies-api.git
synced 2025-10-27 17:38:51 +05:00
Rewrite api to Go
This commit is contained in:
353
pkg/services/auth.go
Normal file
353
pkg/services/auth.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
db *mongo.Database
|
||||
jwtSecret string
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService) *AuthService {
|
||||
service := &AuthService{
|
||||
db: db,
|
||||
jwtSecret: jwtSecret,
|
||||
emailService: emailService,
|
||||
}
|
||||
|
||||
// Запускаем тест подключения к базе данных
|
||||
go service.testDatabaseConnection()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// testDatabaseConnection тестирует подключение к базе данных и выводит информацию о пользователях
|
||||
func (s *AuthService) testDatabaseConnection() {
|
||||
ctx := context.Background()
|
||||
|
||||
fmt.Println("=== DATABASE CONNECTION TEST ===")
|
||||
|
||||
// Проверяем подключение
|
||||
err := s.db.Client().Ping(ctx, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Database connection failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Database connection successful\n")
|
||||
fmt.Printf("📊 Database name: %s\n", s.db.Name())
|
||||
|
||||
// Получаем список всех коллекций
|
||||
collections, err := s.db.ListCollectionNames(ctx, bson.M{})
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to list collections: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("📁 Available collections: %v\n", collections)
|
||||
|
||||
// Проверяем коллекцию users
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
// Подсчитываем количество документов
|
||||
count, err := collection.CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to count users: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("👥 Total users in database: %d\n", count)
|
||||
|
||||
if count > 0 {
|
||||
// Показываем всех пользователей
|
||||
cursor, err := collection.Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to find users: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var users []bson.M
|
||||
if err := cursor.All(ctx, &users); err != nil {
|
||||
fmt.Printf("❌ Failed to decode users: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("📋 All users in database:\n")
|
||||
for i, user := range users {
|
||||
fmt.Printf(" %d. Email: %s, Name: %s, Verified: %v\n",
|
||||
i+1,
|
||||
user["email"],
|
||||
user["name"],
|
||||
user["verified"])
|
||||
}
|
||||
|
||||
// Тестируем поиск конкретного пользователя
|
||||
fmt.Printf("\n🔍 Testing specific user search:\n")
|
||||
testEmails := []string{"neo.movies.mail@gmail.com", "fenixoffc@gmail.com", "test@example.com"}
|
||||
|
||||
for _, email := range testEmails {
|
||||
var user bson.M
|
||||
err := collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
|
||||
if err != nil {
|
||||
fmt.Printf(" ❌ User %s: NOT FOUND (%v)\n", email, err)
|
||||
} else {
|
||||
fmt.Printf(" ✅ User %s: FOUND (Name: %s, Verified: %v)\n",
|
||||
email,
|
||||
user["name"],
|
||||
user["verified"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== END DATABASE TEST ===")
|
||||
}
|
||||
|
||||
// Генерация 6-значного кода
|
||||
func (s *AuthService) generateVerificationCode() string {
|
||||
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
// Проверяем, не существует ли уже пользователь с таким email
|
||||
var existingUser models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser)
|
||||
if err == nil {
|
||||
return nil, errors.New("email already registered")
|
||||
}
|
||||
|
||||
// Хешируем пароль
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Генерируем код верификации
|
||||
code := s.generateVerificationCode()
|
||||
codeExpires := time.Now().Add(10 * time.Minute) // 10 минут
|
||||
|
||||
// Создаем нового пользователя (НЕ ВЕРИФИЦИРОВАННОГО)
|
||||
user := models.User{
|
||||
ID: primitive.NewObjectID(),
|
||||
Email: req.Email,
|
||||
Password: string(hashedPassword),
|
||||
Name: req.Name,
|
||||
Favorites: []string{},
|
||||
Verified: false,
|
||||
VerificationCode: code,
|
||||
VerificationExpires: codeExpires,
|
||||
IsAdmin: false,
|
||||
AdminVerified: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err = collection.InsertOne(context.Background(), user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Отправляем код верификации на email
|
||||
if s.emailService != nil {
|
||||
go s.emailService.SendVerificationEmail(user.Email, code)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Registered. Check email for verification code.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
fmt.Printf("🔍 Login attempt for email: %s\n", req.Email)
|
||||
fmt.Printf("📊 Database name: %s\n", s.db.Name())
|
||||
fmt.Printf("📁 Collection name: %s\n", collection.Name())
|
||||
|
||||
// Находим пользователя по email (точно как в JavaScript)
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ User not found: %v\n", err)
|
||||
return nil, errors.New("User not found")
|
||||
}
|
||||
|
||||
// Проверяем верификацию email (точно как в JavaScript)
|
||||
if !user.Verified {
|
||||
return nil, errors.New("Account not activated. Please verify your email.")
|
||||
}
|
||||
|
||||
// Проверяем пароль (точно как в JavaScript)
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
|
||||
if err != nil {
|
||||
return nil, errors.New("Invalid password")
|
||||
}
|
||||
|
||||
// Генерируем JWT токен
|
||||
token, err := s.generateJWT(user.ID.Hex())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.AuthResponse{
|
||||
Token: token,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err = collection.FindOne(context.Background(), bson.M{"_id": objectID}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
_, err = collection.UpdateOne(
|
||||
context.Background(),
|
||||
bson.M{"_id": objectID},
|
||||
bson.M{"$set": updates},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetUserByID(userID)
|
||||
}
|
||||
|
||||
func (s *AuthService) generateJWT(userID string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 дней
|
||||
"iat": time.Now().Unix(),
|
||||
"jti": uuid.New().String(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.jwtSecret))
|
||||
}
|
||||
|
||||
// Верификация email
|
||||
func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
if user.Verified {
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Email already verified",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Проверяем код и срок действия
|
||||
if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) {
|
||||
return nil, errors.New("invalid or expired verification code")
|
||||
}
|
||||
|
||||
// Верифицируем пользователя
|
||||
_, err = collection.UpdateOne(
|
||||
context.Background(),
|
||||
bson.M{"email": req.Email},
|
||||
bson.M{
|
||||
"$set": bson.M{"verified": true},
|
||||
"$unset": bson.M{
|
||||
"verificationCode": "",
|
||||
"verificationExpires": "",
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Email verified successfully",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Повторная отправка кода верификации
|
||||
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
if user.Verified {
|
||||
return nil, errors.New("email already verified")
|
||||
}
|
||||
|
||||
// Генерируем новый код
|
||||
code := s.generateVerificationCode()
|
||||
codeExpires := time.Now().Add(10 * time.Minute)
|
||||
|
||||
// Обновляем код в базе
|
||||
_, err = collection.UpdateOne(
|
||||
context.Background(),
|
||||
bson.M{"email": req.Email},
|
||||
bson.M{
|
||||
"$set": bson.M{
|
||||
"verificationCode": code,
|
||||
"verificationExpires": codeExpires,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Отправляем новый код на email
|
||||
if s.emailService != nil {
|
||||
go s.emailService.SendVerificationEmail(user.Email, code)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Verification code sent to your email",
|
||||
}, nil
|
||||
}
|
||||
150
pkg/services/email.go
Normal file
150
pkg/services/email.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"neomovies-api/pkg/config"
|
||||
)
|
||||
|
||||
type EmailService struct {
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewEmailService(cfg *config.Config) *EmailService {
|
||||
return &EmailService{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
type EmailOptions struct {
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
IsHTML bool
|
||||
}
|
||||
|
||||
func (s *EmailService) SendEmail(options *EmailOptions) error {
|
||||
if s.config.GmailUser == "" || s.config.GmailPassword == "" {
|
||||
return fmt.Errorf("Gmail credentials not configured")
|
||||
}
|
||||
|
||||
// Gmail SMTP конфигурация
|
||||
smtpHost := "smtp.gmail.com"
|
||||
smtpPort := "587"
|
||||
auth := smtp.PlainAuth("", s.config.GmailUser, s.config.GmailPassword, smtpHost)
|
||||
|
||||
// Создаем заголовки email
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = s.config.GmailUser
|
||||
headers["To"] = strings.Join(options.To, ",")
|
||||
headers["Subject"] = options.Subject
|
||||
|
||||
if options.IsHTML {
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=UTF-8"
|
||||
}
|
||||
|
||||
// Формируем сообщение
|
||||
message := ""
|
||||
for key, value := range headers {
|
||||
message += fmt.Sprintf("%s: %s\r\n", key, value)
|
||||
}
|
||||
message += "\r\n" + options.Body
|
||||
|
||||
// Отправляем email
|
||||
err := smtp.SendMail(
|
||||
smtpHost+":"+smtpPort,
|
||||
auth,
|
||||
s.config.GmailUser,
|
||||
options.To,
|
||||
[]byte(message),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Предустановленные шаблоны email
|
||||
func (s *EmailService) SendVerificationEmail(userEmail, code string) error {
|
||||
options := &EmailOptions{
|
||||
To: []string{userEmail},
|
||||
Subject: "Подтверждение регистрации Neo Movies",
|
||||
Body: fmt.Sprintf(`
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #2196f3;">Neo Movies</h1>
|
||||
<p>Здравствуйте!</p>
|
||||
<p>Для завершения регистрации введите этот код:</p>
|
||||
<div style="
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
letter-spacing: 4px;
|
||||
margin: 20px 0;
|
||||
">
|
||||
%s
|
||||
</div>
|
||||
<p>Код действителен в течение 10 минут.</p>
|
||||
<p>Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо.</p>
|
||||
</div>
|
||||
`, code),
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return s.SendEmail(options)
|
||||
}
|
||||
|
||||
func (s *EmailService) SendPasswordResetEmail(userEmail, resetToken string) error {
|
||||
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, resetToken)
|
||||
|
||||
options := &EmailOptions{
|
||||
To: []string{userEmail},
|
||||
Subject: "Сброс пароля Neo Movies",
|
||||
Body: fmt.Sprintf(`
|
||||
<html>
|
||||
<body>
|
||||
<h2>Сброс пароля</h2>
|
||||
<p>Вы запросили сброс пароля для вашего аккаунта Neo Movies.</p>
|
||||
<p>Нажмите на ссылку ниже, чтобы создать новый пароль:</p>
|
||||
<p><a href="%s">Сбросить пароль</a></p>
|
||||
<p>Ссылка действительна в течение 1 часа.</p>
|
||||
<p>Если вы не запрашивали сброс пароля, проигнорируйте это сообщение.</p>
|
||||
<br>
|
||||
<p>С уважением,<br>Команда Neo Movies</p>
|
||||
</body>
|
||||
</html>
|
||||
`, resetURL),
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return s.SendEmail(options)
|
||||
}
|
||||
|
||||
func (s *EmailService) SendMovieRecommendationEmail(userEmail, userName string, movies []string) error {
|
||||
moviesList := ""
|
||||
for _, movie := range movies {
|
||||
moviesList += fmt.Sprintf("<li>%s</li>", movie)
|
||||
}
|
||||
|
||||
options := &EmailOptions{
|
||||
To: []string{userEmail},
|
||||
Subject: "Новые рекомендации фильмов от Neo Movies",
|
||||
Body: fmt.Sprintf(`
|
||||
<html>
|
||||
<body>
|
||||
<h2>Привет, %s!</h2>
|
||||
<p>У нас есть новые рекомендации фильмов специально для вас:</p>
|
||||
<ul>%s</ul>
|
||||
<p>Заходите в приложение, чтобы узнать больше деталей!</p>
|
||||
<br>
|
||||
<p>С уважением,<br>Команда Neo Movies</p>
|
||||
</body>
|
||||
</html>
|
||||
`, userName, moviesList),
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return s.SendEmail(options)
|
||||
}
|
||||
110
pkg/services/movie.go
Normal file
110
pkg/services/movie.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type MovieService struct {
|
||||
db *mongo.Database
|
||||
tmdb *TMDBService
|
||||
}
|
||||
|
||||
func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService {
|
||||
return &MovieService{
|
||||
db: db,
|
||||
tmdb: tmdb,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MovieService) Search(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.SearchMovies(query, page, language, region, year)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetByID(id int, language string) (*models.Movie, error) {
|
||||
return s.tmdb.GetMovie(id, language)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetPopular(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetPopularMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetTopRated(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetTopRatedMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetUpcoming(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetUpcomingMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetNowPlaying(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetNowPlayingMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetMovieRecommendations(id, page, language)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetSimilarMovies(id, page, language)
|
||||
}
|
||||
|
||||
func (s *MovieService) AddToFavorites(userID string, movieID string) error {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
filter := bson.M{"_id": userID}
|
||||
update := bson.M{
|
||||
"$addToSet": bson.M{"favorites": movieID},
|
||||
}
|
||||
|
||||
_, err := collection.UpdateOne(context.Background(), filter, update)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MovieService) RemoveFromFavorites(userID string, movieID string) error {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
filter := bson.M{"_id": userID}
|
||||
update := bson.M{
|
||||
"$pull": bson.M{"favorites": movieID},
|
||||
}
|
||||
|
||||
_, err := collection.UpdateOne(context.Background(), filter, update)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MovieService) GetFavorites(userID string, language string) ([]models.Movie, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var movies []models.Movie
|
||||
for _, movieIDStr := range user.Favorites {
|
||||
movieID, err := strconv.Atoi(movieIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
movie, err := s.tmdb.GetMovie(movieID, language)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
movies = append(movies, *movie)
|
||||
}
|
||||
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||
return s.tmdb.GetMovieExternalIDs(id)
|
||||
}
|
||||
212
pkg/services/reactions.go
Normal file
212
pkg/services/reactions.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type ReactionsService struct {
|
||||
db *mongo.Database
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewReactionsService(db *mongo.Database) *ReactionsService {
|
||||
return &ReactionsService{
|
||||
db: db,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
const CUB_API_URL = "https://cub.rip/api"
|
||||
|
||||
var VALID_REACTIONS = []string{"fire", "nice", "think", "bore", "shit"}
|
||||
|
||||
// Получить счетчики реакций для медиа из внешнего API (cub.rip)
|
||||
func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) {
|
||||
cubID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", CUB_API_URL, cubID))
|
||||
if err != nil {
|
||||
return &models.ReactionCounts{}, nil // Возвращаем пустые счетчики при ошибке
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &models.ReactionCounts{}, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return &models.ReactionCounts{}, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Result []struct {
|
||||
Type string `json:"type"`
|
||||
Counter int `json:"counter"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return &models.ReactionCounts{}, nil
|
||||
}
|
||||
|
||||
// Преобразуем в нашу структуру
|
||||
counts := &models.ReactionCounts{}
|
||||
for _, reaction := range response.Result {
|
||||
switch reaction.Type {
|
||||
case "fire":
|
||||
counts.Fire = reaction.Counter
|
||||
case "nice":
|
||||
counts.Nice = reaction.Counter
|
||||
case "think":
|
||||
counts.Think = reaction.Counter
|
||||
case "bore":
|
||||
counts.Bore = reaction.Counter
|
||||
case "shit":
|
||||
counts.Shit = reaction.Counter
|
||||
}
|
||||
}
|
||||
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// Получить реакцию пользователя для медиа
|
||||
func (s *ReactionsService) GetUserReaction(userID, mediaType, mediaID string) (*models.Reaction, error) {
|
||||
collection := s.db.Collection("reactions")
|
||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
var reaction models.Reaction
|
||||
err := collection.FindOne(context.Background(), bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": fullMediaID,
|
||||
}).Decode(&reaction)
|
||||
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, nil // Реакции нет
|
||||
}
|
||||
|
||||
return &reaction, err
|
||||
}
|
||||
|
||||
// Установить реакцию пользователя
|
||||
func (s *ReactionsService) SetUserReaction(userID, mediaType, mediaID, reactionType string) error {
|
||||
// Проверяем валидность типа реакции
|
||||
if !s.isValidReactionType(reactionType) {
|
||||
return fmt.Errorf("invalid reaction type: %s", reactionType)
|
||||
}
|
||||
|
||||
collection := s.db.Collection("reactions")
|
||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
// Создаем или обновляем реакцию
|
||||
filter := bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": fullMediaID,
|
||||
}
|
||||
|
||||
reaction := models.Reaction{
|
||||
UserID: userID,
|
||||
MediaID: fullMediaID,
|
||||
Type: reactionType,
|
||||
Created: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
update := bson.M{
|
||||
"$set": reaction,
|
||||
}
|
||||
|
||||
upsert := true
|
||||
_, err := collection.UpdateOne(context.Background(), filter, update, &options.UpdateOptions{
|
||||
Upsert: &upsert,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Отправляем реакцию в cub.rip API
|
||||
go s.sendReactionToCub(fullMediaID, reactionType)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Удалить реакцию пользователя
|
||||
func (s *ReactionsService) RemoveUserReaction(userID, mediaType, mediaID string) error {
|
||||
collection := s.db.Collection("reactions")
|
||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
_, err := collection.DeleteOne(context.Background(), bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": fullMediaID,
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Получить все реакции пользователя
|
||||
func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models.Reaction, error) {
|
||||
collection := s.db.Collection("reactions")
|
||||
|
||||
ctx := context.Background()
|
||||
cursor, err := collection.Find(ctx, bson.M{"userId": userID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var reactions []models.Reaction
|
||||
if err := cursor.All(ctx, &reactions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reactions, nil
|
||||
}
|
||||
|
||||
func (s *ReactionsService) isValidReactionType(reactionType string) bool {
|
||||
for _, valid := range VALID_REACTIONS {
|
||||
if valid == reactionType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Отправка реакции в cub.rip API (асинхронно)
|
||||
func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
|
||||
// Формируем запрос к cub.rip API
|
||||
url := fmt.Sprintf("%s/reactions/set", CUB_API_URL)
|
||||
|
||||
data := map[string]string{
|
||||
"mediaId": mediaID,
|
||||
"type": reactionType,
|
||||
}
|
||||
|
||||
_, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// В данном случае мы отправляем простой POST запрос
|
||||
// В будущем можно доработать для отправки JSON данных
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Логируем результат (в продакшене лучше использовать структурированное логирование)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType)
|
||||
}
|
||||
}
|
||||
479
pkg/services/tmdb.go
Normal file
479
pkg/services/tmdb.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type TMDBService struct {
|
||||
accessToken string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewTMDBService(accessToken string) *TMDBService {
|
||||
return &TMDBService{
|
||||
accessToken: accessToken,
|
||||
baseURL: "https://api.themoviedb.org/3",
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TMDBService) makeRequest(endpoint string, target interface{}) error {
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Используем Bearer токен вместо API key в query параметрах
|
||||
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("TMDB API error: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchMovies(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
params.Set("include_adult", "false")
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
params.Set("region", region)
|
||||
}
|
||||
|
||||
if year > 0 {
|
||||
params.Set("year", strconv.Itoa(year))
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/search/movie?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchMulti(query string, page int, language string) (*models.MultiSearchResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
params.Set("include_adult", "false")
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/search/multi?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.MultiSearchResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Фильтруем результаты: убираем "person", и без названия
|
||||
filteredResults := make([]models.MultiSearchResult, 0)
|
||||
for _, result := range response.Results {
|
||||
if result.MediaType == "person" {
|
||||
continue
|
||||
}
|
||||
|
||||
hasTitle := false
|
||||
if result.MediaType == "movie" && result.Title != "" {
|
||||
hasTitle = true
|
||||
} else if result.MediaType == "tv" && result.Name != "" {
|
||||
hasTitle = true
|
||||
}
|
||||
|
||||
if hasTitle {
|
||||
filteredResults = append(filteredResults, result)
|
||||
}
|
||||
}
|
||||
|
||||
response.Results = filteredResults
|
||||
response.TotalResults = len(filteredResults)
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
params.Set("include_adult", "false")
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if firstAirDateYear > 0 {
|
||||
params.Set("first_air_date_year", strconv.Itoa(firstAirDateYear))
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/search/tv?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) {
|
||||
params := url.Values{}
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var movie models.Movie
|
||||
err := s.makeRequest(endpoint, &movie)
|
||||
return &movie, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) {
|
||||
params := url.Values{}
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var tvShow models.TVShow
|
||||
err := s.makeRequest(endpoint, &tvShow)
|
||||
return &tvShow, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) {
|
||||
params := url.Values{}
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/genre/%s/list?%s", s.baseURL, mediaType, params.Encode())
|
||||
|
||||
var response models.GenresResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) {
|
||||
// Получаем жанры фильмов
|
||||
movieGenres, err := s.GetGenres("movie", "ru-RU")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Получаем жанры сериалов
|
||||
tvGenres, err := s.GetGenres("tv", "ru-RU")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Объединяем жанры, убирая дубликаты
|
||||
allGenres := make(map[int]models.Genre)
|
||||
|
||||
for _, genre := range movieGenres.Genres {
|
||||
allGenres[genre.ID] = genre
|
||||
}
|
||||
|
||||
for _, genre := range tvGenres.Genres {
|
||||
allGenres[genre.ID] = genre
|
||||
}
|
||||
|
||||
// Преобразуем обратно в слайс
|
||||
var genres []models.Genre
|
||||
for _, genre := range allGenres {
|
||||
genres = append(genres, genre)
|
||||
}
|
||||
|
||||
return &models.GenresResponse{Genres: genres}, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetPopularMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
params.Set("region", region)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/popular?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
params.Set("region", region)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/top_rated?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
params.Set("region", region)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/upcoming?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
params.Set("region", region)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/now_playing?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/%d/recommendations?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/%d/similar?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/on_the_air?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/%d/recommendations?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/%d/similar?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||
endpoint := fmt.Sprintf("%s/movie/%d/external_ids", s.baseURL, id)
|
||||
|
||||
var ids models.ExternalIDs
|
||||
err := s.makeRequest(endpoint, &ids)
|
||||
return &ids, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTVExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||
endpoint := fmt.Sprintf("%s/tv/%d/external_ids", s.baseURL, id)
|
||||
|
||||
var ids models.ExternalIDs
|
||||
err := s.makeRequest(endpoint, &ids)
|
||||
return &ids, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
params.Set("with_genres", strconv.Itoa(genreID))
|
||||
params.Set("sort_by", "popularity.desc")
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/discover/movie?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
832
pkg/services/torrent.go
Normal file
832
pkg/services/torrent.go
Normal file
@@ -0,0 +1,832 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type TorrentService struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewTorrentService() *TorrentService {
|
||||
return &TorrentService{
|
||||
client: &http.Client{Timeout: 8 * time.Second},
|
||||
baseURL: "http://redapi.cfhttp.top",
|
||||
apiKey: "", // Может быть установлен через переменные окружения
|
||||
}
|
||||
}
|
||||
|
||||
// SearchTorrents - основной метод поиска торрентов через RedAPI
|
||||
func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) {
|
||||
searchParams := url.Values{}
|
||||
|
||||
// Добавляем все параметры поиска
|
||||
for key, value := range params {
|
||||
if value != "" {
|
||||
if key == "category" {
|
||||
searchParams.Add("category[]", value)
|
||||
} else {
|
||||
searchParams.Add(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.apiKey != "" {
|
||||
searchParams.Add("apikey", s.apiKey)
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s/api/v2.0/indexers/all/results?%s", s.baseURL, searchParams.Encode())
|
||||
|
||||
resp, err := s.client.Get(searchURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search torrents: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var redAPIResponse models.RedAPIResponse
|
||||
if err := json.Unmarshal(body, &redAPIResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
results := s.parseRedAPIResults(redAPIResponse)
|
||||
|
||||
return &models.TorrentSearchResponse{
|
||||
Query: params["query"],
|
||||
Results: results,
|
||||
Total: len(results),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseRedAPIResults преобразует результаты RedAPI в наш формат
|
||||
func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models.TorrentResult {
|
||||
var results []models.TorrentResult
|
||||
|
||||
for _, torrent := range data.Results {
|
||||
// Обрабатываем размер - может быть строкой или числом
|
||||
var sizeStr string
|
||||
switch v := torrent.Size.(type) {
|
||||
case string:
|
||||
sizeStr = v
|
||||
case float64:
|
||||
sizeStr = fmt.Sprintf("%.0f", v)
|
||||
case int:
|
||||
sizeStr = fmt.Sprintf("%d", v)
|
||||
default:
|
||||
sizeStr = ""
|
||||
}
|
||||
|
||||
result := models.TorrentResult{
|
||||
Title: torrent.Title,
|
||||
Tracker: torrent.Tracker,
|
||||
Size: sizeStr,
|
||||
Seeders: torrent.Seeders,
|
||||
Peers: torrent.Peers,
|
||||
MagnetLink: torrent.MagnetUri,
|
||||
PublishDate: torrent.PublishDate,
|
||||
Category: torrent.CategoryDesc,
|
||||
Details: torrent.Details,
|
||||
Source: "RedAPI",
|
||||
}
|
||||
|
||||
// Добавляем информацию из Info если она есть
|
||||
if torrent.Info != nil {
|
||||
// Обрабатываем качество - может быть строкой или числом
|
||||
switch v := torrent.Info.Quality.(type) {
|
||||
case string:
|
||||
result.Quality = v
|
||||
case float64:
|
||||
result.Quality = fmt.Sprintf("%.0fp", v)
|
||||
case int:
|
||||
result.Quality = fmt.Sprintf("%dp", v)
|
||||
}
|
||||
|
||||
result.Voice = torrent.Info.Voices
|
||||
result.Types = torrent.Info.Types
|
||||
result.Seasons = torrent.Info.Seasons
|
||||
}
|
||||
|
||||
// Если качество не определено через Info, пытаемся извлечь из названия
|
||||
if result.Quality == "" {
|
||||
result.Quality = s.ExtractQuality(result.Title)
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
|
||||
func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) {
|
||||
// Получаем информацию о фильме/сериале из TMDB
|
||||
title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get title from TMDB: %w", err)
|
||||
}
|
||||
|
||||
// Формируем параметры поиска
|
||||
params := make(map[string]string)
|
||||
params["imdb"] = imdbID
|
||||
params["title"] = title
|
||||
params["title_original"] = originalTitle
|
||||
params["year"] = year
|
||||
|
||||
// Устанавливаем тип контента и категорию
|
||||
switch mediaType {
|
||||
case "movie":
|
||||
params["is_serial"] = "1"
|
||||
params["category"] = "2000"
|
||||
case "tv", "series":
|
||||
params["is_serial"] = "2"
|
||||
params["category"] = "5000"
|
||||
case "anime":
|
||||
params["is_serial"] = "5"
|
||||
params["category"] = "5070"
|
||||
default:
|
||||
params["is_serial"] = "1"
|
||||
params["category"] = "2000"
|
||||
}
|
||||
|
||||
// Добавляем сезон если указан
|
||||
if options != nil && options.Season != nil {
|
||||
params["season"] = strconv.Itoa(*options.Season)
|
||||
}
|
||||
|
||||
// Выполняем поиск
|
||||
response, err := s.SearchTorrents(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Применяем фильтрацию
|
||||
if options != nil {
|
||||
response.Results = s.FilterByContentType(response.Results, options.ContentType)
|
||||
response.Results = s.FilterTorrents(response.Results, options)
|
||||
response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder)
|
||||
response.Total = len(response.Results)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// SearchMovies - поиск фильмов с дополнительной фильтрацией
|
||||
func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
|
||||
params := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"is_serial": "1",
|
||||
"category": "2000",
|
||||
}
|
||||
|
||||
response, err := s.SearchTorrents(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.Results = s.FilterByContentType(response.Results, "movie")
|
||||
response.Total = len(response.Results)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// SearchSeries - поиск сериалов с поддержкой fallback и фильтрации по сезону
|
||||
func (s *TorrentService) SearchSeries(title, originalTitle, year string, season *int) (*models.TorrentSearchResponse, error) {
|
||||
params := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"is_serial": "2",
|
||||
"category": "5000",
|
||||
}
|
||||
if season != nil {
|
||||
params["season"] = strconv.Itoa(*season)
|
||||
}
|
||||
|
||||
response, err := s.SearchTorrents(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Если указан сезон и результатов мало, делаем fallback-поиск без сезона и фильтруем на клиенте
|
||||
if season != nil && len(response.Results) < 5 {
|
||||
paramsNoSeason := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"is_serial": "2",
|
||||
"category": "5000",
|
||||
}
|
||||
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
|
||||
if err == nil {
|
||||
filtered := s.filterBySeason(fallbackResp.Results, *season)
|
||||
// Объединяем и убираем дубликаты по MagnetLink
|
||||
all := append(response.Results, filtered...)
|
||||
unique := make([]models.TorrentResult, 0, len(all))
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range all {
|
||||
if !seen[t.MagnetLink] {
|
||||
unique = append(unique, t)
|
||||
seen[t.MagnetLink] = true
|
||||
}
|
||||
}
|
||||
response.Results = unique
|
||||
}
|
||||
}
|
||||
|
||||
response.Results = s.FilterByContentType(response.Results, "serial")
|
||||
response.Total = len(response.Results)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// filterBySeason - фильтрация результатов по сезону (аналогично JS)
|
||||
func (s *TorrentService) filterBySeason(results []models.TorrentResult, season int) []models.TorrentResult {
|
||||
if season == 0 {
|
||||
return results
|
||||
}
|
||||
filtered := make([]models.TorrentResult, 0, len(results))
|
||||
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||
for _, torrent := range results {
|
||||
found := false
|
||||
// Проверяем поле seasons
|
||||
for _, s := range torrent.Seasons {
|
||||
if s == season {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
filtered = append(filtered, torrent)
|
||||
continue
|
||||
}
|
||||
// Проверяем в названии
|
||||
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber == season {
|
||||
filtered = append(filtered, torrent)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// SearchAnime - поиск аниме
|
||||
func (s *TorrentService) SearchAnime(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
|
||||
params := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"is_serial": "5",
|
||||
"category": "5070",
|
||||
}
|
||||
|
||||
response, err := s.SearchTorrents(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.Results = s.FilterByContentType(response.Results, "anime")
|
||||
response.Total = len(response.Results)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// AllohaResponse - структура ответа от Alloha API
|
||||
type AllohaResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Year int `json:"year"`
|
||||
Category int `json:"category"` // 1-фильм, 2-сериал
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// getMovieInfoByIMDB - получение информации через Alloha API (как в JavaScript версии)
|
||||
func (s *TorrentService) getMovieInfoByIMDB(imdbID string) (string, string, string, error) {
|
||||
// Используем тот же токен что и в JavaScript версии
|
||||
endpoint := fmt.Sprintf("https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&imdb=%s", imdbID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
var allohaResponse AllohaResponse
|
||||
if err := json.Unmarshal(body, &allohaResponse); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if allohaResponse.Status != "success" {
|
||||
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
|
||||
}
|
||||
|
||||
title := allohaResponse.Data.Name
|
||||
originalTitle := allohaResponse.Data.OriginalName
|
||||
year := ""
|
||||
if allohaResponse.Data.Year > 0 {
|
||||
year = strconv.Itoa(allohaResponse.Data.Year)
|
||||
}
|
||||
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
// getTitleFromTMDB - получение информации из TMDB (с fallback на Alloha API)
|
||||
func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, mediaType string) (string, string, string, error) {
|
||||
// Сначала пробуем Alloha API (как в JavaScript версии)
|
||||
title, originalTitle, year, err := s.getMovieInfoByIMDB(imdbID)
|
||||
if err == nil {
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
// Если Alloha API не работает, пробуем TMDB API
|
||||
endpoint := fmt.Sprintf("https://api.themoviedb.org/3/find/%s", imdbID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("external_source", "imdb_id")
|
||||
params.Set("language", "ru-RU")
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tmdbService.accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
var findResponse struct {
|
||||
MovieResults []struct {
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
} `json:"movie_results"`
|
||||
TVResults []struct {
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
} `json:"tv_results"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &findResponse); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if mediaType == "movie" && len(findResponse.MovieResults) > 0 {
|
||||
movie := findResponse.MovieResults[0]
|
||||
title := movie.Title
|
||||
originalTitle := movie.OriginalTitle
|
||||
year := ""
|
||||
if movie.ReleaseDate != "" {
|
||||
year = movie.ReleaseDate[:4]
|
||||
}
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
if (mediaType == "tv" || mediaType == "series") && len(findResponse.TVResults) > 0 {
|
||||
tv := findResponse.TVResults[0]
|
||||
title := tv.Name
|
||||
originalTitle := tv.OriginalName
|
||||
year := ""
|
||||
if tv.FirstAirDate != "" {
|
||||
year = tv.FirstAirDate[:4]
|
||||
}
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
|
||||
}
|
||||
|
||||
// FilterByContentType - фильтрация по типу контента
|
||||
func (s *TorrentService) FilterByContentType(results []models.TorrentResult, contentType string) []models.TorrentResult {
|
||||
if contentType == "" {
|
||||
return results
|
||||
}
|
||||
|
||||
var filtered []models.TorrentResult
|
||||
|
||||
for _, torrent := range results {
|
||||
// Фильтрация по полю types, если оно есть
|
||||
if len(torrent.Types) > 0 {
|
||||
switch contentType {
|
||||
case "movie":
|
||||
if s.containsAny(torrent.Types, []string{"movie", "multfilm", "documovie"}) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "serial":
|
||||
if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "anime":
|
||||
if s.contains(torrent.Types, "anime") {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Фильтрация по названию, если types недоступно
|
||||
title := strings.ToLower(torrent.Title)
|
||||
switch contentType {
|
||||
case "movie":
|
||||
if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "serial":
|
||||
if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "anime":
|
||||
if torrent.Category == "TV/Anime" || regexp.MustCompile(`(?i)anime`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// FilterTorrents - фильтрация торрентов по опциям
|
||||
func (s *TorrentService) FilterTorrents(torrents []models.TorrentResult, options *models.TorrentSearchOptions) []models.TorrentResult {
|
||||
if options == nil {
|
||||
return torrents
|
||||
}
|
||||
|
||||
var filtered []models.TorrentResult
|
||||
|
||||
for _, torrent := range torrents {
|
||||
// Фильтрация по качеству
|
||||
if len(options.Quality) > 0 {
|
||||
found := false
|
||||
for _, quality := range options.Quality {
|
||||
if strings.EqualFold(torrent.Quality, quality) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по минимальному качеству
|
||||
if options.MinQuality != "" && !s.qualityMeetsMinimum(torrent.Quality, options.MinQuality) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Фильтрация по максимальному качеству
|
||||
if options.MaxQuality != "" && !s.qualityMeetsMaximum(torrent.Quality, options.MaxQuality) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Исключение качеств
|
||||
if len(options.ExcludeQualities) > 0 {
|
||||
excluded := false
|
||||
for _, excludeQuality := range options.ExcludeQualities {
|
||||
if strings.EqualFold(torrent.Quality, excludeQuality) {
|
||||
excluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if excluded {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по HDR
|
||||
if options.HDR != nil {
|
||||
hasHDR := regexp.MustCompile(`(?i)(hdr|dolby.vision|dv)`).MatchString(torrent.Title)
|
||||
if *options.HDR != hasHDR {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по HEVC
|
||||
if options.HEVC != nil {
|
||||
hasHEVC := regexp.MustCompile(`(?i)(hevc|h\.265|x265)`).MatchString(torrent.Title)
|
||||
if *options.HEVC != hasHEVC {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по сезону (дополнительная на клиенте)
|
||||
if options.Season != nil {
|
||||
if !s.matchesSeason(torrent, *options.Season) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// matchesSeason - проверка соответствия сезону
|
||||
func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int) bool {
|
||||
// Проверяем в поле seasons
|
||||
for _, s := range torrent.Seasons {
|
||||
if s == season {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем в названии
|
||||
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber == season {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractQuality - извлечение качества из названия
|
||||
func (s *TorrentService) ExtractQuality(title string) string {
|
||||
title = strings.ToUpper(title)
|
||||
|
||||
qualityPatterns := []struct {
|
||||
pattern string
|
||||
quality string
|
||||
}{
|
||||
{`2160P|4K`, "2160p"},
|
||||
{`1440P`, "1440p"},
|
||||
{`1080P`, "1080p"},
|
||||
{`720P`, "720p"},
|
||||
{`480P`, "480p"},
|
||||
{`360P`, "360p"},
|
||||
}
|
||||
|
||||
for _, qp := range qualityPatterns {
|
||||
if matched, _ := regexp.MatchString(qp.pattern, title); matched {
|
||||
if qp.quality == "2160p" {
|
||||
return "4K"
|
||||
}
|
||||
return qp.quality
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// sortTorrents - сортировка результатов
|
||||
func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, sortOrder string) []models.TorrentResult {
|
||||
if sortBy == "" {
|
||||
sortBy = "seeders"
|
||||
}
|
||||
if sortOrder == "" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
|
||||
sort.Slice(torrents, func(i, j int) bool {
|
||||
var less bool
|
||||
|
||||
switch sortBy {
|
||||
case "seeders":
|
||||
less = torrents[i].Seeders < torrents[j].Seeders
|
||||
case "size":
|
||||
less = s.compareSizes(torrents[i].Size, torrents[j].Size)
|
||||
case "date":
|
||||
less = torrents[i].PublishDate < torrents[j].PublishDate
|
||||
default:
|
||||
less = torrents[i].Seeders < torrents[j].Seeders
|
||||
}
|
||||
|
||||
if sortOrder == "asc" {
|
||||
return less
|
||||
}
|
||||
return !less
|
||||
})
|
||||
|
||||
return torrents
|
||||
}
|
||||
|
||||
// GroupByQuality - группировка по качеству
|
||||
func (s *TorrentService) GroupByQuality(results []models.TorrentResult) map[string][]models.TorrentResult {
|
||||
groups := make(map[string][]models.TorrentResult)
|
||||
|
||||
for _, torrent := range results {
|
||||
quality := torrent.Quality
|
||||
if quality == "" {
|
||||
quality = "unknown"
|
||||
}
|
||||
|
||||
// Объединяем 4K и 2160p в одну группу
|
||||
if quality == "2160p" {
|
||||
quality = "4K"
|
||||
}
|
||||
|
||||
groups[quality] = append(groups[quality], torrent)
|
||||
}
|
||||
|
||||
// Сортируем торренты внутри каждой группы по сидам
|
||||
for quality := range groups {
|
||||
sort.Slice(groups[quality], func(i, j int) bool {
|
||||
return groups[quality][i].Seeders > groups[quality][j].Seeders
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// GroupBySeason - группировка по сезонам
|
||||
func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[string][]models.TorrentResult {
|
||||
groups := make(map[string][]models.TorrentResult)
|
||||
|
||||
for _, torrent := range results {
|
||||
seasons := make(map[int]bool)
|
||||
|
||||
// Извлекаем сезоны из поля seasons
|
||||
for _, season := range torrent.Seasons {
|
||||
seasons[season] = true
|
||||
}
|
||||
|
||||
// Извлекаем сезоны из названия
|
||||
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber > 0 {
|
||||
seasons[seasonNumber] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Если сезоны не найдены, добавляем в группу "unknown"
|
||||
if len(seasons) == 0 {
|
||||
groups["Неизвестно"] = append(groups["Неизвестно"], torrent)
|
||||
} else {
|
||||
// Добавляем торрент во все соответствующие группы сезонов
|
||||
for season := range seasons {
|
||||
seasonKey := fmt.Sprintf("Сезон %d", season)
|
||||
// Проверяем дубликаты
|
||||
found := false
|
||||
for _, existing := range groups[seasonKey] {
|
||||
if existing.MagnetLink == torrent.MagnetLink {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
groups[seasonKey] = append(groups[seasonKey], torrent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем торренты внутри каждой группы по сидам
|
||||
for season := range groups {
|
||||
sort.Slice(groups[season], func(i, j int) bool {
|
||||
return groups[season][i].Seeders > groups[season][j].Seeders
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// GetAvailableSeasons - получение доступных сезонов для сериала
|
||||
func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string) ([]int, error) {
|
||||
response, err := s.SearchSeries(title, originalTitle, year, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seasonsSet := make(map[int]bool)
|
||||
|
||||
for _, torrent := range response.Results {
|
||||
// Извлекаем из поля seasons
|
||||
for _, season := range torrent.Seasons {
|
||||
seasonsSet[season] = true
|
||||
}
|
||||
|
||||
// Извлекаем из названия
|
||||
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber > 0 {
|
||||
seasonsSet[seasonNumber] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var seasons []int
|
||||
for season := range seasonsSet {
|
||||
seasons = append(seasons, season)
|
||||
}
|
||||
|
||||
sort.Ints(seasons)
|
||||
return seasons, nil
|
||||
}
|
||||
|
||||
// Вспомогательные функции
|
||||
func (s *TorrentService) qualityMeetsMinimum(quality, minQuality string) bool {
|
||||
qualityOrder := map[string]int{
|
||||
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
|
||||
}
|
||||
|
||||
currentLevel := qualityOrder[strings.ToLower(quality)]
|
||||
minLevel := qualityOrder[strings.ToLower(minQuality)]
|
||||
|
||||
return currentLevel >= minLevel
|
||||
}
|
||||
|
||||
func (s *TorrentService) qualityMeetsMaximum(quality, maxQuality string) bool {
|
||||
qualityOrder := map[string]int{
|
||||
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
|
||||
}
|
||||
|
||||
currentLevel := qualityOrder[strings.ToLower(quality)]
|
||||
maxLevel := qualityOrder[strings.ToLower(maxQuality)]
|
||||
|
||||
return currentLevel <= maxLevel
|
||||
}
|
||||
|
||||
func (s *TorrentService) compareSizes(size1, size2 string) bool {
|
||||
// Простое сравнение размеров (можно улучшить)
|
||||
return len(size1) < len(size2)
|
||||
}
|
||||
|
||||
func (s *TorrentService) contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *TorrentService) containsAny(slice []string, items []string) bool {
|
||||
for _, item := range items {
|
||||
if s.contains(slice, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
55
pkg/services/tv.go
Normal file
55
pkg/services/tv.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type TVService struct {
|
||||
db *mongo.Database
|
||||
tmdb *TMDBService
|
||||
}
|
||||
|
||||
func NewTVService(db *mongo.Database, tmdb *TMDBService) *TVService {
|
||||
return &TVService{
|
||||
db: db,
|
||||
tmdb: tmdb,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TVService) Search(query string, page int, language string, year int) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.SearchTVShows(query, page, language, year)
|
||||
}
|
||||
|
||||
func (s *TVService) GetByID(id int, language string) (*models.TVShow, error) {
|
||||
return s.tmdb.GetTVShow(id, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetPopular(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetPopularTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetTopRated(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetTopRatedTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetOnTheAir(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetOnTheAirTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetAiringToday(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetAiringTodayTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetTVRecommendations(id, page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetSimilarTVShows(id, page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||
return s.tmdb.GetTVExternalIDs(id)
|
||||
}
|
||||
Reference in New Issue
Block a user