This commit is contained in:
2025-08-07 18:25:43 +00:00
parent 8131c7db8c
commit 83ecac92c4
5 changed files with 397 additions and 258 deletions

View File

@@ -1,48 +1,16 @@
# Neo Movies API (Go Version) 🎬 # Neo Movies API
> Современный API для поиска фильмов и сериалов, портированный с Node.js на Go REST API для поиска и получения информации о фильмах, использующий TMDB API.
## 🚀 Особенности ## Особенности
- **Высокая производительность** - написан на Go - Поиск фильмов
- 🔒 **JWT аутентификация** с email верификацией - Информация о фильмах
- 🎭 **TMDB API интеграция** для данных о фильмах/сериалах - Популярные фильмы
- 📧 **Email уведомления** через Gmail SMTP - Топ рейтинговые фильмы
- 🔍 **Полнотекстовый поиск** фильмов и сериалов - Предстоящие фильмы
- **Система избранного** для пользователей - Swagger документация
- 🎨 **Современная документация** с Scalar API Reference - Поддержка русского языка
- 🌐 **CORS поддержка** для фронтенд интеграции
- ☁️ **Готов к деплою на Vercel**
## 📚 Основные функции
### 🔐 Аутентификация
- **Регистрация** с email верификацией (6-значный код)
- **Авторизация** JWT токенами
- **Управление профилем** пользователя
- **Email подтверждение** обязательно для входа
### 🎬 TMDB интеграция
- Поиск фильмов и сериалов
- Популярные, топ-рейтинговые, предстоящие
- Детальная информация с трейлерами и актерами
- Рекомендации и похожие фильмы
- Мультипоиск по всем типам контента
### ⭐ Пользовательские функции
- Добавление фильмов в избранное
- Персональные списки
- История просмотров
### 🎭 Плееры
- **Alloha Player** интеграция
- **Lumex Player** интеграция
### 📦 Дополнительно
- **Торренты** - поиск по IMDB ID с фильтрацией
- **Реакции** - лайки/дизлайки с внешним API
- **Изображения** - прокси для TMDB с кэшированием
- **Категории** - жанры и фильмы по категориям
## 🛠 Быстрый старт ## 🛠 Быстрый старт

59
main.go
View File

@@ -1,7 +1,7 @@
package main package main
import ( import (
"log" "fmt"
"net/http" "net/http"
"os" "os"
@@ -13,13 +13,14 @@ import (
"neomovies-api/pkg/database" "neomovies-api/pkg/database"
appHandlers "neomovies-api/pkg/handlers" appHandlers "neomovies-api/pkg/handlers"
"neomovies-api/pkg/middleware" "neomovies-api/pkg/middleware"
"neomovies-api/pkg/monitor"
"neomovies-api/pkg/services" "neomovies-api/pkg/services"
) )
func main() { func main() {
// Загружаем переменные окружения // Загружаем переменные окружения
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
log.Println("Warning: .env file not found") // Не выводим предупреждение в продакшене
} }
// Инициализируем конфигурацию // Инициализируем конфигурацию
@@ -28,14 +29,15 @@ func main() {
// Подключаемся к базе данных // Подключаемся к базе данных
db, err := database.Connect(cfg.MongoURI) db, err := database.Connect(cfg.MongoURI)
if err != nil { if err != nil {
log.Fatal("Failed to connect to database:", err) fmt.Printf("Failed to connect to database: %v\n", err)
os.Exit(1)
} }
defer database.Disconnect() defer database.Disconnect()
// Инициализируем сервисы // Инициализируем сервисы
tmdbService := services.NewTMDBService(cfg.TMDBAccessToken) tmdbService := services.NewTMDBService(cfg.TMDBAccessToken)
emailService := services.NewEmailService(cfg) emailService := services.NewEmailService(cfg)
authService := services.NewAuthService(db, cfg.JWTSecret, emailService) authService := services.NewAuthService(db, cfg.JWTSecret, emailService, cfg.BaseURL)
movieService := services.NewMovieService(db, tmdbService) movieService := services.NewMovieService(db, tmdbService)
tvService := services.NewTVService(db, tmdbService) tvService := services.NewTVService(db, tmdbService)
torrentService := services.NewTorrentService() torrentService := services.NewTorrentService()
@@ -75,11 +77,11 @@ func main() {
// Категории // Категории
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET") api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET")// Плееры - ИСПРАВЛЕНО: добавлены параметры {imdb_id} api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET")
// Плееры // Плееры - ИСПРАВЛЕНО: добавлены параметры {imdb_id}
api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET") api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
// Торренты // Торренты
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
@@ -95,7 +97,7 @@ func main() {
// Изображения (прокси для TMDB) // Изображения (прокси для TMDB)
api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET") api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET")
// Маршруты для фильмов (некоторые публичные, некоторые приватные) // Маршруты для фильмов
api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET") api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET")
api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET") api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET")
api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET") api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET")
@@ -104,6 +106,7 @@ func main() {
api.HandleFunc("/movies/{id}", movieHandler.GetByID).Methods("GET") api.HandleFunc("/movies/{id}", movieHandler.GetByID).Methods("GET")
api.HandleFunc("/movies/{id}/recommendations", movieHandler.GetRecommendations).Methods("GET") api.HandleFunc("/movies/{id}/recommendations", movieHandler.GetRecommendations).Methods("GET")
api.HandleFunc("/movies/{id}/similar", movieHandler.GetSimilar).Methods("GET") api.HandleFunc("/movies/{id}/similar", movieHandler.GetSimilar).Methods("GET")
api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET")
// Маршруты для сериалов // Маршруты для сериалов
api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET") api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET")
@@ -114,6 +117,7 @@ func main() {
api.HandleFunc("/tv/{id}", tvHandler.GetByID).Methods("GET") api.HandleFunc("/tv/{id}", tvHandler.GetByID).Methods("GET")
api.HandleFunc("/tv/{id}/recommendations", tvHandler.GetRecommendations).Methods("GET") api.HandleFunc("/tv/{id}/recommendations", tvHandler.GetRecommendations).Methods("GET")
api.HandleFunc("/tv/{id}/similar", tvHandler.GetSimilar).Methods("GET") api.HandleFunc("/tv/{id}/similar", tvHandler.GetSimilar).Methods("GET")
api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET")
// Приватные маршруты (требуют авторизации) // Приватные маршруты (требуют авторизации)
protected := api.PathPrefix("").Subrouter() protected := api.PathPrefix("").Subrouter()
@@ -134,22 +138,43 @@ func main() {
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE")
protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET") protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET")
// CORS и другие middleware // CORS middleware
corsHandler := handlers.CORS( corsHandler := handlers.CORS(
handlers.AllowedOrigins([]string{"*"}), handlers.AllowedOrigins([]string{"*"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}), handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}),
handlers.AllowCredentials(), handlers.AllowCredentials(),
) )
// Применяем мониторинг запросов только в development
var finalHandler http.Handler
if cfg.NodeEnv == "development" {
// Добавляем middleware для мониторинга запросов
r.Use(monitor.RequestMonitor())
finalHandler = corsHandler(r)
// Выводим заголовок мониторинга
fmt.Println("\n🚀 NeoMovies API Server")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Printf("📡 Server: http://localhost:%s\n", cfg.Port)
fmt.Printf("📚 Docs: http://localhost:%s/\n", cfg.Port)
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Printf("%-6s %-3s │ %-60s │ %8s\n", "METHOD", "CODE", "ENDPOINT", "TIME")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
} else {
finalHandler = corsHandler(r)
fmt.Printf("✅ Server starting on port %s\n", cfg.Port)
}
// Определяем порт // Определяем порт
port := os.Getenv("PORT") port := cfg.Port
if port == "" { if port == "" {
port = "3000" port = "3000"
} }
log.Printf("Server starting on port %s", port) // Запускаем сервер
log.Printf("API documentation available at: http://localhost:%s/", port) if err := http.ListenAndServe(":"+port, finalHandler); err != nil {
fmt.Printf("❌ Server failed to start: %v\n", err)
log.Fatal(http.ListenAndServe(":"+port, corsHandler(r))) os.Exit(1)
}
} }

View File

@@ -259,16 +259,30 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
}, },
"/api/v1/torrents/search/{imdbId}": map[string]interface{}{ "/api/v1/torrents/search/{imdbId}": map[string]interface{}{
"get": map[string]interface{}{ "get": map[string]interface{}{
"summary": "Поиск торрентов", "summary": "Поиск торрентов",
"description": "Поиск торрентов по IMDB ID", "description": "Поиск торрентов по IMDB ID",
"tags": []string{"Torrents"}, "tags": []string{"Torrents"},
"parameters": []map[string]interface{}{ "parameters": []map[string]interface{}{
{ {
"name": "imdbId", "name": "imdbId",
"in": "path", "in": "path",
"required": true, "required": true,
"schema": map[string]string{"type": "string"}, "schema": map[string]string{"type": "string"},
"description": "IMDB ID фильма", "description": "IMDB ID фильма или сериала",
},
{
"name": "type",
"in": "query",
"required": true,
"schema": map[string]interface{}{"type": "string", "enum": []string{"movie", "tv", "serial"}},
"description": "Тип контента: movie (фильм) или tv/serial (сериал)",
},
{
"name": "season",
"in": "query",
"required": false,
"schema": map[string]interface{}{"type": "integer"},
"description": "Номер сезона (для сериалов)",
}, },
}, },
"responses": map[string]interface{}{ "responses": map[string]interface{}{
@@ -545,6 +559,38 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
}, },
}, },
}, },
// --- Добавленный блок для DELETE-запроса ---
"delete": map[string]interface{}{
"summary": "Удалить аккаунт пользователя",
"description": "Полное и безвозвратное удаление аккаунта пользователя и всех связанных с ним данных (избранное, реакции)",
"tags": []string{"Authentication"},
"security": []map[string][]string{
{"bearerAuth": []string{}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Аккаунт успешно удален",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"success": map[string]interface{}{"type": "boolean"},
"message": map[string]interface{}{"type": "string"},
},
},
},
},
},
"401": map[string]interface{}{
"description": "Неавторизованный запрос",
},
"500": map[string]interface{}{
"description": "Внутренняя ошибка сервера",
},
},
},
// ------------------------------------------
}, },
"/api/v1/movies/search": map[string]interface{}{ "/api/v1/movies/search": map[string]interface{}{
"get": map[string]interface{}{ "get": map[string]interface{}{
@@ -861,11 +907,13 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
"name": "page", "name": "page",
"in": "query", "in": "query",
"schema": map[string]string{"type": "integer", "default": "1"}, "schema": map[string]string{"type": "integer", "default": "1"},
"description": "Номер страницы",
}, },
{ {
"name": "language", "name": "language",
"in": "query", "in": "query",
"schema": map[string]string{"type": "string", "default": "ru-RU"}, "schema": map[string]string{"type": "string", "default": "ru-RU"},
"description": "Язык ответа",
}, },
}, },
"responses": map[string]interface{}{ "responses": map[string]interface{}{
@@ -1329,4 +1377,4 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
}, },
}, },
} }
} }

View File

@@ -4,7 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"math/rand" "math/rand"
"net/http"
"sync"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -17,133 +20,56 @@ import (
"neomovies-api/pkg/models" "neomovies-api/pkg/models"
) )
// AuthService contains the database connection, JWT secret, and email service.
type AuthService struct { type AuthService struct {
db *mongo.Database db *mongo.Database
jwtSecret string jwtSecret string
emailService *EmailService emailService *EmailService
cubAPIURL string
} }
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService) *AuthService { // Reaction represents a reaction entry in the database.
type Reaction struct {
MediaID string `bson:"mediaId"`
Type string `bson:"type"`
UserID primitive.ObjectID `bson:"userId"`
}
// NewAuthService creates and initializes a new AuthService.
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService, cubAPIURL string) *AuthService {
service := &AuthService{ service := &AuthService{
db: db, db: db,
jwtSecret: jwtSecret, jwtSecret: jwtSecret,
emailService: emailService, emailService: emailService,
cubAPIURL: cubAPIURL,
} }
// Запускаем тест подключения к базе данных
go service.testDatabaseConnection()
return service return service
} }
// testDatabaseConnection тестирует подключение к базе данных и выводит информацию о пользователях // generateVerificationCode creates a 6-digit verification code.
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 { func (s *AuthService) generateVerificationCode() string {
return fmt.Sprintf("%06d", rand.Intn(900000)+100000) return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
} }
// Register registers a new user.
func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) { func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users") collection := s.db.Collection("users")
// Проверяем, не существует ли уже пользователь с таким email
var existingUser models.User var existingUser models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser) err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser)
if err == nil { if err == nil {
return nil, errors.New("email already registered") return nil, errors.New("email already registered")
} }
// Хешируем пароль
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Генерируем код верификации
code := s.generateVerificationCode() code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute) // 10 минут codeExpires := time.Now().Add(10 * time.Minute)
// Создаем нового пользователя (НЕ ВЕРИФИЦИРОВАННОГО)
user := models.User{ user := models.User{
ID: primitive.NewObjectID(), ID: primitive.NewObjectID(),
Email: req.Email, Email: req.Email,
@@ -164,7 +90,6 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface
return nil, err return nil, err
} }
// Отправляем код верификации на email
if s.emailService != nil { if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code) go s.emailService.SendVerificationEmail(user.Email, code)
} }
@@ -175,33 +100,25 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface
}, nil }, nil
} }
// Login authenticates a user.
func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) { func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) {
collection := s.db.Collection("users") 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 var user models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user) err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
if err != nil { if err != nil {
fmt.Printf("❌ User not found: %v\n", err)
return nil, errors.New("User not found") return nil, errors.New("User not found")
} }
// Проверяем верификацию email (точно как в JavaScript)
if !user.Verified { if !user.Verified {
return nil, errors.New("Account not activated. Please verify your email.") return nil, errors.New("Account not activated. Please verify your email.")
} }
// Проверяем пароль (точно как в JavaScript)
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
if err != nil { if err != nil {
return nil, errors.New("Invalid password") return nil, errors.New("Invalid password")
} }
// Генерируем JWT токен
token, err := s.generateJWT(user.ID.Hex()) token, err := s.generateJWT(user.ID.Hex())
if err != nil { if err != nil {
return nil, err return nil, err
@@ -213,6 +130,7 @@ func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, erro
}, nil }, nil
} }
// GetUserByID retrieves a user by their ID.
func (s *AuthService) GetUserByID(userID string) (*models.User, error) { func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
collection := s.db.Collection("users") collection := s.db.Collection("users")
@@ -230,6 +148,7 @@ func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
return &user, nil return &user, nil
} }
// UpdateUser updates a user's information.
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) { func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
collection := s.db.Collection("users") collection := s.db.Collection("users")
@@ -252,10 +171,11 @@ func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, e
return s.GetUserByID(userID) return s.GetUserByID(userID)
} }
// generateJWT generates a new JWT for a given user ID.
func (s *AuthService) generateJWT(userID string) (string, error) { func (s *AuthService) generateJWT(userID string) (string, error) {
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"user_id": userID, "user_id": userID,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 дней "exp": time.Now().Add(time.Hour * 24 * 7).Unix(),
"iat": time.Now().Unix(), "iat": time.Now().Unix(),
"jti": uuid.New().String(), "jti": uuid.New().String(),
} }
@@ -264,7 +184,7 @@ func (s *AuthService) generateJWT(userID string) (string, error) {
return token.SignedString([]byte(s.jwtSecret)) return token.SignedString([]byte(s.jwtSecret))
} }
// Верификация email // VerifyEmail verifies a user's email with a code.
func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) { func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users") collection := s.db.Collection("users")
@@ -281,12 +201,10 @@ func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]int
}, nil }, nil
} }
// Проверяем код и срок действия
if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) { if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) {
return nil, errors.New("invalid or expired verification code") return nil, errors.New("invalid or expired verification code")
} }
// Верифицируем пользователя
_, err = collection.UpdateOne( _, err = collection.UpdateOne(
context.Background(), context.Background(),
bson.M{"email": req.Email}, bson.M{"email": req.Email},
@@ -308,7 +226,7 @@ func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]int
}, nil }, nil
} }
// Повторная отправка кода верификации // ResendVerificationCode sends a new verification email.
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) { func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users") collection := s.db.Collection("users")
@@ -322,11 +240,9 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
return nil, errors.New("email already verified") return nil, errors.New("email already verified")
} }
// Генерируем новый код
code := s.generateVerificationCode() code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute) codeExpires := time.Now().Add(10 * time.Minute)
// Обновляем код в базе
_, err = collection.UpdateOne( _, err = collection.UpdateOne(
context.Background(), context.Background(),
bson.M{"email": req.Email}, bson.M{"email": req.Email},
@@ -341,7 +257,6 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
return nil, err return nil, err
} }
// Отправляем новый код на email
if s.emailService != nil { if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code) go s.emailService.SendVerificationEmail(user.Email, code)
} }
@@ -350,4 +265,77 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
"success": true, "success": true,
"message": "Verification code sent to your email", "message": "Verification code sent to your email",
}, nil }, nil
} }
// DeleteAccount deletes a user and all associated data.
func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error {
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return fmt.Errorf("invalid user ID format: %w", err)
}
// Step 1: Find user reactions and remove them from cub.rip
if s.cubAPIURL != "" {
reactionsCollection := s.db.Collection("reactions")
var userReactions []Reaction
cursor, err := reactionsCollection.Find(ctx, bson.M{"userId": objectID})
if err != nil {
return fmt.Errorf("failed to find user reactions: %w", err)
}
if err = cursor.All(ctx, &userReactions); err != nil {
return fmt.Errorf("failed to decode user reactions: %w", err)
}
var wg sync.WaitGroup
client := &http.Client{Timeout: 10 * time.Second}
for _, reaction := range userReactions {
wg.Add(1)
go func(r Reaction) {
defer wg.Done()
url := fmt.Sprintf("%s/reactions/remove/%s/%s", s.cubAPIURL, r.MediaID, r.Type)
req, err := http.NewRequestWithContext(ctx, "POST", url, nil) // or "DELETE"
if err != nil {
// Log the error but don't stop the process
fmt.Printf("failed to create request for cub.rip: %v\n", err)
return
}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("failed to send request to cub.rip: %v\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("cub.rip API responded with status %d: %s\n", resp.StatusCode, body)
}
}(reaction)
}
wg.Wait()
}
// Step 2: Delete all user-related data from the database
usersCollection := s.db.Collection("users")
favoritesCollection := s.db.Collection("favorites")
reactionsCollection := s.db.Collection("reactions")
_, err = usersCollection.DeleteOne(ctx, bson.M{"_id": objectID})
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
_, err = favoritesCollection.DeleteMany(ctx, bson.M{"userId": objectID})
if err != nil {
return fmt.Errorf("failed to delete user favorites: %w", err)
}
_, err = reactionsCollection.DeleteMany(ctx, bson.M{"userId": objectID})
if err != nil {
return fmt.Errorf("failed to delete user reactions: %w", err)
}
return nil
}

View File

@@ -32,7 +32,7 @@ func NewTorrentService() *TorrentService {
// SearchTorrents - основной метод поиска торрентов через RedAPI // SearchTorrents - основной метод поиска торрентов через RedAPI
func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) { func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) {
searchParams := url.Values{} searchParams := url.Values{}
// Добавляем все параметры поиска // Добавляем все параметры поиска
for key, value := range params { for key, value := range params {
if value != "" { if value != "" {
@@ -43,13 +43,13 @@ func (s *TorrentService) SearchTorrents(params map[string]string) (*models.Torre
} }
} }
} }
if s.apiKey != "" { if s.apiKey != "" {
searchParams.Add("apikey", s.apiKey) searchParams.Add("apikey", s.apiKey)
} }
searchURL := fmt.Sprintf("%s/api/v2.0/indexers/all/results?%s", s.baseURL, searchParams.Encode()) searchURL := fmt.Sprintf("%s/api/v2.0/indexers/all/results?%s", s.baseURL, searchParams.Encode())
resp, err := s.client.Get(searchURL) resp, err := s.client.Get(searchURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to search torrents: %w", err) return nil, fmt.Errorf("failed to search torrents: %w", err)
@@ -67,7 +67,7 @@ func (s *TorrentService) SearchTorrents(params map[string]string) (*models.Torre
} }
results := s.parseRedAPIResults(redAPIResponse) results := s.parseRedAPIResults(redAPIResponse)
return &models.TorrentSearchResponse{ return &models.TorrentSearchResponse{
Query: params["query"], Query: params["query"],
Results: results, Results: results,
@@ -78,7 +78,7 @@ func (s *TorrentService) SearchTorrents(params map[string]string) (*models.Torre
// parseRedAPIResults преобразует результаты RedAPI в наш формат // parseRedAPIResults преобразует результаты RedAPI в наш формат
func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models.TorrentResult { func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models.TorrentResult {
var results []models.TorrentResult var results []models.TorrentResult
for _, torrent := range data.Results { for _, torrent := range data.Results {
// Обрабатываем размер - может быть строкой или числом // Обрабатываем размер - может быть строкой или числом
var sizeStr string var sizeStr string
@@ -92,7 +92,7 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
default: default:
sizeStr = "" sizeStr = ""
} }
result := models.TorrentResult{ result := models.TorrentResult{
Title: torrent.Title, Title: torrent.Title,
Tracker: torrent.Tracker, Tracker: torrent.Tracker,
@@ -105,7 +105,7 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
Details: torrent.Details, Details: torrent.Details,
Source: "RedAPI", Source: "RedAPI",
} }
// Добавляем информацию из Info если она есть // Добавляем информацию из Info если она есть
if torrent.Info != nil { if torrent.Info != nil {
// Обрабатываем качество - может быть строкой или числом // Обрабатываем качество - может быть строкой или числом
@@ -117,56 +117,55 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
case int: case int:
result.Quality = fmt.Sprintf("%dp", v) result.Quality = fmt.Sprintf("%dp", v)
} }
result.Voice = torrent.Info.Voices result.Voice = torrent.Info.Voices
result.Types = torrent.Info.Types result.Types = torrent.Info.Types
result.Seasons = torrent.Info.Seasons result.Seasons = torrent.Info.Seasons
} }
// Если качество не определено через Info, пытаемся извлечь из названия // Если качество не определено через Info, пытаемся извлечь из названия
if result.Quality == "" { if result.Quality == "" {
result.Quality = s.ExtractQuality(result.Title) result.Quality = s.ExtractQuality(result.Title)
} }
results = append(results, result) results = append(results, result)
} }
return results return results
} }
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций // SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) { func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) {
// Получаем информацию о фильме/сериале из TMDB // Получаем информацию о фильме/сериале из TMDB
// ИСПРАВЛЕНО: Теперь присваиваются все 4 возвращаемых значения
title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType) title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get title from TMDB: %w", err) return nil, fmt.Errorf("failed to get title from TMDB: %w", err)
} }
// Формируем параметры поиска // Создаем параметры поиска для RedAPI
params := make(map[string]string) params := map[string]string{
params["imdb"] = imdbID "imdb": imdbID,
params["title"] = title "query": title,
params["title_original"] = originalTitle "title_original": originalTitle,
params["year"] = year "year": year,
}
// Устанавливаем тип контента и категорию
// Определяем тип контента для API
switch mediaType { switch mediaType {
case "movie": case "movie":
params["is_serial"] = "1" params["is_serial"] = "1"
params["category"] = "2000" params["category"] = "2000"
case "tv", "series": case "serial", "series", "tv":
params["is_serial"] = "2" params["is_serial"] = "2"
params["category"] = "5000" params["category"] = "5000"
case "anime": case "anime":
params["is_serial"] = "5" params["is_serial"] = "5"
params["category"] = "5070" params["category"] = "5070"
default:
params["is_serial"] = "1"
params["category"] = "2000"
} }
// Добавляем сезон если указан // Добавляем сезон, если он указан
if options != nil && options.Season != nil { if options != nil && options.Season != nil && *options.Season > 0 {
params["season"] = strconv.Itoa(*options.Season) params["season"] = strconv.Itoa(*options.Season)
} }
@@ -181,12 +180,40 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID
response.Results = s.FilterByContentType(response.Results, options.ContentType) response.Results = s.FilterByContentType(response.Results, options.ContentType)
response.Results = s.FilterTorrents(response.Results, options) response.Results = s.FilterTorrents(response.Results, options)
response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder) response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder)
response.Total = len(response.Results) }
response.Total = len(response.Results)
// Fallback для сериалов, если результатов мало
if len(response.Results) < 5 && (mediaType == "serial" || mediaType == "series" || mediaType == "tv") && options != nil && options.Season != nil {
paramsNoSeason := map[string]string{
"imdb": imdbID,
"query": title,
"title_original": originalTitle,
"year": year,
"is_serial": "2",
"category": "5000",
}
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
if err == nil {
filtered := s.filterBySeason(fallbackResp.Results, *options.Season)
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.Total = len(response.Results)
}
} }
return response, nil return response, nil
} }
// SearchMovies - поиск фильмов с дополнительной фильтрацией // SearchMovies - поиск фильмов с дополнительной фильтрацией
func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) { func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
params := map[string]string{ params := map[string]string{
@@ -196,15 +223,15 @@ func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*model
"is_serial": "1", "is_serial": "1",
"category": "2000", "category": "2000",
} }
response, err := s.SearchTorrents(params) response, err := s.SearchTorrents(params)
if err != nil { if err != nil {
return nil, err return nil, err
} }
response.Results = s.FilterByContentType(response.Results, "movie") response.Results = s.FilterByContentType(response.Results, "movie")
response.Total = len(response.Results) response.Total = len(response.Results)
return response, nil return response, nil
} }
@@ -304,15 +331,15 @@ func (s *TorrentService) SearchAnime(title, originalTitle, year string) (*models
"is_serial": "5", "is_serial": "5",
"category": "5070", "category": "5070",
} }
response, err := s.SearchTorrents(params) response, err := s.SearchTorrents(params)
if err != nil { if err != nil {
return nil, err return nil, err
} }
response.Results = s.FilterByContentType(response.Results, "anime") response.Results = s.FilterByContentType(response.Results, "anime")
response.Total = len(response.Results) response.Total = len(response.Results)
return response, nil return response, nil
} }
@@ -331,7 +358,7 @@ type AllohaResponse struct {
func (s *TorrentService) getMovieInfoByIMDB(imdbID string) (string, string, string, error) { func (s *TorrentService) getMovieInfoByIMDB(imdbID string) (string, string, string, error) {
// Используем тот же токен что и в JavaScript версии // Используем тот же токен что и в JavaScript версии
endpoint := fmt.Sprintf("https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&imdb=%s", imdbID) endpoint := fmt.Sprintf("https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&imdb=%s", imdbID)
req, err := http.NewRequest("GET", endpoint, nil) req, err := http.NewRequest("GET", endpoint, nil)
if err != nil { if err != nil {
return "", "", "", err return "", "", "", err
@@ -377,7 +404,7 @@ func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, medi
// Если Alloha API не работает, пробуем TMDB API // Если Alloha API не работает, пробуем TMDB API
endpoint := fmt.Sprintf("https://api.themoviedb.org/3/find/%s", imdbID) endpoint := fmt.Sprintf("https://api.themoviedb.org/3/find/%s", imdbID)
req, err := http.NewRequest("GET", endpoint, nil) req, err := http.NewRequest("GET", endpoint, nil)
if err != nil { if err != nil {
return "", "", "", err return "", "", "", err
@@ -444,14 +471,12 @@ func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, medi
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID) return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
} }
// FilterByContentType - фильтрация по типу контента // FilterByContentType - фильтрация по типу контента (как в JS)
func (s *TorrentService) FilterByContentType(results []models.TorrentResult, contentType string) []models.TorrentResult { func (s *TorrentService) FilterByContentType(results []models.TorrentResult, contentType string) []models.TorrentResult {
if contentType == "" { if contentType == "" {
return results return results
} }
var filtered []models.TorrentResult var filtered []models.TorrentResult
for _, torrent := range results { for _, torrent := range results {
// Фильтрация по полю types, если оно есть // Фильтрация по полю types, если оно есть
if len(torrent.Types) > 0 { if len(torrent.Types) > 0 {
@@ -460,7 +485,7 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
if s.containsAny(torrent.Types, []string{"movie", "multfilm", "documovie"}) { if s.containsAny(torrent.Types, []string{"movie", "multfilm", "documovie"}) {
filtered = append(filtered, torrent) filtered = append(filtered, torrent)
} }
case "serial": case "serial", "series", "tv":
if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) { if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) {
filtered = append(filtered, torrent) filtered = append(filtered, torrent)
} }
@@ -471,7 +496,6 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
} }
continue continue
} }
// Фильтрация по названию, если types недоступно // Фильтрация по названию, если types недоступно
title := strings.ToLower(torrent.Title) title := strings.ToLower(torrent.Title)
switch contentType { switch contentType {
@@ -479,7 +503,7 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) { if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
filtered = append(filtered, torrent) filtered = append(filtered, torrent)
} }
case "serial": case "serial", "series", "tv":
if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) { if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
filtered = append(filtered, torrent) filtered = append(filtered, torrent)
} }
@@ -491,7 +515,6 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
filtered = append(filtered, torrent) filtered = append(filtered, torrent)
} }
} }
return filtered return filtered
} }
@@ -579,7 +602,7 @@ func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int)
return true return true
} }
} }
// Проверяем в названии // Проверяем в названии
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`) seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1) matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
@@ -594,14 +617,14 @@ func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int)
return true return true
} }
} }
return false return false
} }
// ExtractQuality - извлечение качества из названия // ExtractQuality - извлечение качества из названия
func (s *TorrentService) ExtractQuality(title string) string { func (s *TorrentService) ExtractQuality(title string) string {
title = strings.ToUpper(title) title = strings.ToUpper(title)
qualityPatterns := []struct { qualityPatterns := []struct {
pattern string pattern string
quality string quality string
@@ -613,7 +636,7 @@ func (s *TorrentService) ExtractQuality(title string) string {
{`480P`, "480p"}, {`480P`, "480p"},
{`360P`, "360p"}, {`360P`, "360p"},
} }
for _, qp := range qualityPatterns { for _, qp := range qualityPatterns {
if matched, _ := regexp.MatchString(qp.pattern, title); matched { if matched, _ := regexp.MatchString(qp.pattern, title); matched {
if qp.quality == "2160p" { if qp.quality == "2160p" {
@@ -622,7 +645,7 @@ func (s *TorrentService) ExtractQuality(title string) string {
return qp.quality return qp.quality
} }
} }
return "Unknown" return "Unknown"
} }
@@ -637,14 +660,16 @@ func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, s
sort.Slice(torrents, func(i, j int) bool { sort.Slice(torrents, func(i, j int) bool {
var less bool var less bool
switch sortBy { switch sortBy {
case "seeders": case "seeders":
less = torrents[i].Seeders < torrents[j].Seeders less = torrents[i].Seeders < torrents[j].Seeders
case "size": case "size":
less = s.compareSizes(torrents[i].Size, torrents[j].Size) less = s.compareSizes(torrents[i].Size, torrents[j].Size)
case "date": case "date":
less = torrents[i].PublishDate < torrents[j].PublishDate t1, _ := time.Parse(time.RFC3339, torrents[i].PublishDate)
t2, _ := time.Parse(time.RFC3339, torrents[j].PublishDate)
less = t1.Before(t2)
default: default:
less = torrents[i].Seeders < torrents[j].Seeders less = torrents[i].Seeders < torrents[j].Seeders
} }
@@ -661,43 +686,43 @@ func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, s
// GroupByQuality - группировка по качеству // GroupByQuality - группировка по качеству
func (s *TorrentService) GroupByQuality(results []models.TorrentResult) map[string][]models.TorrentResult { func (s *TorrentService) GroupByQuality(results []models.TorrentResult) map[string][]models.TorrentResult {
groups := make(map[string][]models.TorrentResult) groups := make(map[string][]models.TorrentResult)
for _, torrent := range results { for _, torrent := range results {
quality := torrent.Quality quality := torrent.Quality
if quality == "" { if quality == "" {
quality = "unknown" quality = "unknown"
} }
// Объединяем 4K и 2160p в одну группу // Объединяем 4K и 2160p в одну группу
if quality == "2160p" { if quality == "2160p" {
quality = "4K" quality = "4K"
} }
groups[quality] = append(groups[quality], torrent) groups[quality] = append(groups[quality], torrent)
} }
// Сортируем торренты внутри каждой группы по сидам // Сортируем торренты внутри каждой группы по сидам
for quality := range groups { for quality := range groups {
sort.Slice(groups[quality], func(i, j int) bool { sort.Slice(groups[quality], func(i, j int) bool {
return groups[quality][i].Seeders > groups[quality][j].Seeders return groups[quality][i].Seeders > groups[quality][j].Seeders
}) })
} }
return groups return groups
} }
// GroupBySeason - группировка по сезонам // GroupBySeason - группировка по сезонам
func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[string][]models.TorrentResult { func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[string][]models.TorrentResult {
groups := make(map[string][]models.TorrentResult) groups := make(map[string][]models.TorrentResult)
for _, torrent := range results { for _, torrent := range results {
seasons := make(map[int]bool) seasons := make(map[int]bool)
// Извлекаем сезоны из поля seasons // Извлекаем сезоны из поля seasons
for _, season := range torrent.Seasons { for _, season := range torrent.Seasons {
seasons[season] = true seasons[season] = true
} }
// Извлекаем сезоны из названия // Извлекаем сезоны из названия
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`) seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1) matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
@@ -712,7 +737,7 @@ func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[strin
seasons[seasonNumber] = true seasons[seasonNumber] = true
} }
} }
// Если сезоны не найдены, добавляем в группу "unknown" // Если сезоны не найдены, добавляем в группу "unknown"
if len(seasons) == 0 { if len(seasons) == 0 {
groups["Неизвестно"] = append(groups["Неизвестно"], torrent) groups["Неизвестно"] = append(groups["Неизвестно"], torrent)
@@ -734,14 +759,14 @@ func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[strin
} }
} }
} }
// Сортируем торренты внутри каждой группы по сидам // Сортируем торренты внутри каждой группы по сидам
for season := range groups { for season := range groups {
sort.Slice(groups[season], func(i, j int) bool { sort.Slice(groups[season], func(i, j int) bool {
return groups[season][i].Seeders > groups[season][j].Seeders return groups[season][i].Seeders > groups[season][j].Seeders
}) })
} }
return groups return groups
} }
@@ -751,15 +776,15 @@ func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
seasonsSet := make(map[int]bool) seasonsSet := make(map[int]bool)
for _, torrent := range response.Results { for _, torrent := range response.Results {
// Извлекаем из поля seasons // Извлекаем из поля seasons
for _, season := range torrent.Seasons { for _, season := range torrent.Seasons {
seasonsSet[season] = true seasonsSet[season] = true
} }
// Извлекаем из названия // Извлекаем из названия
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`) seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1) matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
@@ -775,25 +800,99 @@ func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string)
} }
} }
} }
var seasons []int var seasons []int
for season := range seasonsSet { for season := range seasonsSet {
seasons = append(seasons, season) seasons = append(seasons, season)
} }
sort.Ints(seasons) sort.Ints(seasons)
return seasons, nil return seasons, nil
} }
// Вспомогательные функции // SearchByImdb - поиск по IMDB ID (movie/serial/anime).
func (s *TorrentService) SearchByImdb(imdbID, contentType string, season *int) ([]models.TorrentResult, error) {
if imdbID == "" || !strings.HasPrefix(imdbID, "tt") {
return nil, fmt.Errorf("Неверный формат IMDB ID. Должен быть в формате tt1234567")
}
// НЕ добавляем title, originalTitle, year, чтобы запрос не был слишком строгим.
params := map[string]string{
"imdb": imdbID,
}
// Определяем тип контента для API
switch contentType {
case "movie":
params["is_serial"] = "1"
params["category"] = "2000"
case "serial", "series", "tv":
params["is_serial"] = "2"
params["category"] = "5000"
case "anime":
params["is_serial"] = "5"
params["category"] = "5070"
default:
// Значение по умолчанию на случай неизвестного типа
params["is_serial"] = "1"
params["category"] = "2000"
}
// Параметр season можно оставить, он полезен
if season != nil && *season > 0 {
params["season"] = strconv.Itoa(*season)
}
resp, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
results := resp.Results
// Fallback для сериалов: если указан сезон и результатов мало, ищем без сезона и фильтруем на клиенте
if s.contains([]string{"serial", "series", "tv"}, contentType) && season != nil && len(results) < 5 {
paramsNoSeason := map[string]string{
"imdb": imdbID,
"is_serial": "2",
"category": "5000",
}
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
if err == nil {
filtered := s.filterBySeason(fallbackResp.Results, *season)
// Объединяем и убираем дубликаты по MagnetLink
all := append(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
}
}
results = unique
}
}
// Финальная фильтрация по типу контента на стороне клиента для надежности
results = s.FilterByContentType(results, contentType)
return results, nil
}
// ############# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ #############
func (s *TorrentService) qualityMeetsMinimum(quality, minQuality string) bool { func (s *TorrentService) qualityMeetsMinimum(quality, minQuality string) bool {
qualityOrder := map[string]int{ qualityOrder := map[string]int{
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6, "360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
} }
currentLevel := qualityOrder[strings.ToLower(quality)] currentLevel, ok1 := qualityOrder[strings.ToLower(quality)]
minLevel := qualityOrder[strings.ToLower(minQuality)] minLevel, ok2 := qualityOrder[strings.ToLower(minQuality)]
if !ok1 || !ok2 {
return true // Если качество не определено, не фильтруем
}
return currentLevel >= minLevel return currentLevel >= minLevel
} }
@@ -801,21 +900,32 @@ func (s *TorrentService) qualityMeetsMaximum(quality, maxQuality string) bool {
qualityOrder := map[string]int{ qualityOrder := map[string]int{
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6, "360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
} }
currentLevel := qualityOrder[strings.ToLower(quality)] currentLevel, ok1 := qualityOrder[strings.ToLower(quality)]
maxLevel := qualityOrder[strings.ToLower(maxQuality)] maxLevel, ok2 := qualityOrder[strings.ToLower(maxQuality)]
if !ok1 || !ok2 {
return true // Если качество не определено, не фильтруем
}
return currentLevel <= maxLevel return currentLevel <= maxLevel
} }
func (s *TorrentService) parseSize(sizeStr string) int64 {
val, err := strconv.ParseInt(sizeStr, 10, 64)
if err != nil {
return 0
}
return val
}
func (s *TorrentService) compareSizes(size1, size2 string) bool { func (s *TorrentService) compareSizes(size1, size2 string) bool {
// Простое сравнение размеров (можно улучшить) return s.parseSize(size1) < s.parseSize(size2)
return len(size1) < len(size2)
} }
func (s *TorrentService) contains(slice []string, item string) bool { func (s *TorrentService) contains(slice []string, item string) bool {
for _, s := range slice { for _, s := range slice {
if s == item { if strings.EqualFold(s, item) {
return true return true
} }
} }
@@ -829,4 +939,4 @@ func (s *TorrentService) containsAny(slice []string, items []string) bool {
} }
} }
return false return false
} }