mirror of
https://gitlab.com/foxixus/neomovies-api.git
synced 2025-10-27 17:38:51 +05:00
Bug fix
This commit is contained in:
52
README.md
52
README.md
@@ -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
|
||||
- 🔍 **Полнотекстовый поиск** фильмов и сериалов
|
||||
- ⭐ **Система избранного** для пользователей
|
||||
- 🎨 **Современная документация** с Scalar API Reference
|
||||
- 🌐 **CORS поддержка** для фронтенд интеграции
|
||||
- ☁️ **Готов к деплою на Vercel**
|
||||
|
||||
## 📚 Основные функции
|
||||
|
||||
### 🔐 Аутентификация
|
||||
- **Регистрация** с email верификацией (6-значный код)
|
||||
- **Авторизация** JWT токенами
|
||||
- **Управление профилем** пользователя
|
||||
- **Email подтверждение** обязательно для входа
|
||||
|
||||
### 🎬 TMDB интеграция
|
||||
- Поиск фильмов и сериалов
|
||||
- Популярные, топ-рейтинговые, предстоящие
|
||||
- Детальная информация с трейлерами и актерами
|
||||
- Рекомендации и похожие фильмы
|
||||
- Мультипоиск по всем типам контента
|
||||
|
||||
### ⭐ Пользовательские функции
|
||||
- Добавление фильмов в избранное
|
||||
- Персональные списки
|
||||
- История просмотров
|
||||
|
||||
### 🎭 Плееры
|
||||
- **Alloha Player** интеграция
|
||||
- **Lumex Player** интеграция
|
||||
|
||||
### 📦 Дополнительно
|
||||
- **Торренты** - поиск по IMDB ID с фильтрацией
|
||||
- **Реакции** - лайки/дизлайки с внешним API
|
||||
- **Изображения** - прокси для TMDB с кэшированием
|
||||
- **Категории** - жанры и фильмы по категориям
|
||||
- Поиск фильмов
|
||||
- Информация о фильмах
|
||||
- Популярные фильмы
|
||||
- Топ рейтинговые фильмы
|
||||
- Предстоящие фильмы
|
||||
- Swagger документация
|
||||
- Поддержка русского языка
|
||||
|
||||
## 🛠 Быстрый старт
|
||||
|
||||
|
||||
51
main.go
51
main.go
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@@ -13,13 +13,14 @@ import (
|
||||
"neomovies-api/pkg/database"
|
||||
appHandlers "neomovies-api/pkg/handlers"
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/monitor"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Загружаем переменные окружения
|
||||
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)
|
||||
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()
|
||||
|
||||
// Инициализируем сервисы
|
||||
tmdbService := services.NewTMDBService(cfg.TMDBAccessToken)
|
||||
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)
|
||||
tvService := services.NewTVService(db, tmdbService)
|
||||
torrentService := services.NewTorrentService()
|
||||
@@ -75,9 +77,9 @@ func main() {
|
||||
|
||||
// Категории
|
||||
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/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
|
||||
|
||||
@@ -95,7 +97,7 @@ func main() {
|
||||
// Изображения (прокси для TMDB)
|
||||
api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET")
|
||||
|
||||
// Маршруты для фильмов (некоторые публичные, некоторые приватные)
|
||||
// Маршруты для фильмов
|
||||
api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET")
|
||||
api.HandleFunc("/movies/popular", movieHandler.Popular).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}/recommendations", movieHandler.GetRecommendations).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")
|
||||
@@ -114,6 +117,7 @@ func main() {
|
||||
api.HandleFunc("/tv/{id}", tvHandler.GetByID).Methods("GET")
|
||||
api.HandleFunc("/tv/{id}/recommendations", tvHandler.GetRecommendations).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()
|
||||
@@ -134,7 +138,7 @@ func main() {
|
||||
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE")
|
||||
protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET")
|
||||
|
||||
// CORS и другие middleware
|
||||
// CORS middleware
|
||||
corsHandler := handlers.CORS(
|
||||
handlers.AllowedOrigins([]string{"*"}),
|
||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
|
||||
@@ -142,14 +146,35 @@ func main() {
|
||||
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 == "" {
|
||||
port = "3000"
|
||||
}
|
||||
|
||||
log.Printf("Server starting on port %s", port)
|
||||
log.Printf("API documentation available at: http://localhost:%s/", port)
|
||||
|
||||
log.Fatal(http.ListenAndServe(":"+port, corsHandler(r)))
|
||||
// Запускаем сервер
|
||||
if err := http.ListenAndServe(":"+port, finalHandler); err != nil {
|
||||
fmt.Printf("❌ Server failed to start: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,21 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"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{}{
|
||||
@@ -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{}{
|
||||
"get": map[string]interface{}{
|
||||
@@ -861,11 +907,13 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": map[string]string{"type": "integer", "default": "1"},
|
||||
"description": "Номер страницы",
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"in": "query",
|
||||
"schema": map[string]string{"type": "string", "default": "ru-RU"},
|
||||
"description": "Язык ответа",
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -17,133 +20,56 @@ import (
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
// AuthService contains the database connection, JWT secret, and email service.
|
||||
type AuthService struct {
|
||||
db *mongo.Database
|
||||
jwtSecret string
|
||||
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{
|
||||
db: db,
|
||||
jwtSecret: jwtSecret,
|
||||
emailService: emailService,
|
||||
cubAPIURL: cubAPIURL,
|
||||
}
|
||||
|
||||
// Запускаем тест подключения к базе данных
|
||||
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-значного кода
|
||||
// generateVerificationCode creates a 6-digit verification code.
|
||||
func (s *AuthService) generateVerificationCode() string {
|
||||
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
|
||||
}
|
||||
|
||||
// Register registers a new user.
|
||||
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 минут
|
||||
codeExpires := time.Now().Add(10 * time.Minute)
|
||||
|
||||
// Создаем нового пользователя (НЕ ВЕРИФИЦИРОВАННОГО)
|
||||
user := models.User{
|
||||
ID: primitive.NewObjectID(),
|
||||
Email: req.Email,
|
||||
@@ -164,7 +90,6 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Отправляем код верификации на email
|
||||
if s.emailService != nil {
|
||||
go s.emailService.SendVerificationEmail(user.Email, code)
|
||||
}
|
||||
@@ -175,33 +100,25 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login authenticates a user.
|
||||
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
|
||||
@@ -213,6 +130,7 @@ func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by their ID.
|
||||
func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
@@ -230,6 +148,7 @@ func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates a user's information.
|
||||
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
// generateJWT generates a new JWT for a given user ID.
|
||||
func (s *AuthService) generateJWT(userID string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"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(),
|
||||
"jti": uuid.New().String(),
|
||||
}
|
||||
@@ -264,7 +184,7 @@ func (s *AuthService) generateJWT(userID string) (string, error) {
|
||||
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) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
@@ -281,12 +201,10 @@ func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]int
|
||||
}, 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},
|
||||
@@ -308,7 +226,7 @@ func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]int
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Повторная отправка кода верификации
|
||||
// ResendVerificationCode sends a new verification email.
|
||||
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
@@ -322,11 +240,9 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
|
||||
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},
|
||||
@@ -341,7 +257,6 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Отправляем новый код на email
|
||||
if s.emailService != nil {
|
||||
go s.emailService.SendVerificationEmail(user.Email, code)
|
||||
}
|
||||
@@ -351,3 +266,76 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
|
||||
"message": "Verification code sent to your email",
|
||||
}, 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
|
||||
}
|
||||
|
||||
@@ -137,36 +137,35 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
|
||||
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
|
||||
func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) {
|
||||
// Получаем информацию о фильме/сериале из TMDB
|
||||
// ИСПРАВЛЕНО: Теперь присваиваются все 4 возвращаемых значения
|
||||
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
|
||||
// Создаем параметры поиска для RedAPI
|
||||
params := map[string]string{
|
||||
"imdb": imdbID,
|
||||
"query": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
}
|
||||
|
||||
// Устанавливаем тип контента и категорию
|
||||
// Определяем тип контента для API
|
||||
switch mediaType {
|
||||
case "movie":
|
||||
params["is_serial"] = "1"
|
||||
params["category"] = "2000"
|
||||
case "tv", "series":
|
||||
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"
|
||||
}
|
||||
|
||||
// Добавляем сезон если указан
|
||||
if options != nil && options.Season != nil {
|
||||
// Добавляем сезон, если он указан
|
||||
if options != nil && options.Season != nil && *options.Season > 0 {
|
||||
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.FilterTorrents(response.Results, options)
|
||||
response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
// SearchMovies - поиск фильмов с дополнительной фильтрацией
|
||||
func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
|
||||
params := map[string]string{
|
||||
@@ -444,14 +471,12 @@ func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, medi
|
||||
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 {
|
||||
if contentType == "" {
|
||||
return results
|
||||
}
|
||||
|
||||
var filtered []models.TorrentResult
|
||||
|
||||
for _, torrent := range results {
|
||||
// Фильтрация по полю types, если оно есть
|
||||
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"}) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "serial":
|
||||
case "serial", "series", "tv":
|
||||
if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
@@ -471,7 +496,6 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Фильтрация по названию, если types недоступно
|
||||
title := strings.ToLower(torrent.Title)
|
||||
switch contentType {
|
||||
@@ -479,7 +503,7 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
|
||||
if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "serial":
|
||||
case "serial", "series", "tv":
|
||||
if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
@@ -491,7 +515,6 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -644,7 +667,9 @@ func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, s
|
||||
case "size":
|
||||
less = s.compareSizes(torrents[i].Size, torrents[j].Size)
|
||||
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:
|
||||
less = torrents[i].Seeders < torrents[j].Seeders
|
||||
}
|
||||
@@ -785,14 +810,88 @@ func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string)
|
||||
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 {
|
||||
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)]
|
||||
currentLevel, ok1 := qualityOrder[strings.ToLower(quality)]
|
||||
minLevel, ok2 := qualityOrder[strings.ToLower(minQuality)]
|
||||
|
||||
if !ok1 || !ok2 {
|
||||
return true // Если качество не определено, не фильтруем
|
||||
}
|
||||
|
||||
return currentLevel >= minLevel
|
||||
}
|
||||
@@ -802,20 +901,31 @@ func (s *TorrentService) qualityMeetsMaximum(quality, maxQuality string) bool {
|
||||
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
|
||||
}
|
||||
|
||||
currentLevel := qualityOrder[strings.ToLower(quality)]
|
||||
maxLevel := qualityOrder[strings.ToLower(maxQuality)]
|
||||
currentLevel, ok1 := qualityOrder[strings.ToLower(quality)]
|
||||
maxLevel, ok2 := qualityOrder[strings.ToLower(maxQuality)]
|
||||
|
||||
if !ok1 || !ok2 {
|
||||
return true // Если качество не определено, не фильтруем
|
||||
}
|
||||
|
||||
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 {
|
||||
// Простое сравнение размеров (можно улучшить)
|
||||
return len(size1) < len(size2)
|
||||
return s.parseSize(size1) < s.parseSize(size2)
|
||||
}
|
||||
|
||||
func (s *TorrentService) contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
if strings.EqualFold(s, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user