From 83ecac92c4102222efe3cc2674b742e80abfbdcf Mon Sep 17 00:00:00 2001 From: foxixus Date: Thu, 7 Aug 2025 18:25:43 +0000 Subject: [PATCH] Bug fix --- README.md | 52 ++------ main.go | 59 ++++++--- pkg/handlers/docs.go | 64 ++++++++-- pkg/services/auth.go | 214 +++++++++++++++----------------- pkg/services/torrent.go | 266 ++++++++++++++++++++++++++++------------ 5 files changed, 397 insertions(+), 258 deletions(-) diff --git a/README.md b/README.md index c994769..2f2ab5d 100644 --- a/README.md +++ b/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 документация +- Поддержка русского языка ## 🛠 Быстрый старт diff --git a/main.go b/main.go index 15b90a1..e05a774 100644 --- a/main.go +++ b/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,11 +77,11 @@ 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") - // Плееры - api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET") - api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") + // Плееры - ИСПРАВЛЕНО: добавлены параметры {imdb_id} + api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET") + api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") // Торренты api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).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,22 +138,43 @@ 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"}), - handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}), - handlers.AllowCredentials(), + handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}), + 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) + } } diff --git a/pkg/handlers/docs.go b/pkg/handlers/docs.go index 3265436..257a9aa 100644 --- a/pkg/handlers/docs.go +++ b/pkg/handlers/docs.go @@ -259,16 +259,30 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/torrents/search/{imdbId}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Поиск торрентов", + "summary": "Поиск торрентов", "description": "Поиск торрентов по IMDB ID", - "tags": []string{"Torrents"}, + "tags": []string{"Torrents"}, "parameters": []map[string]interface{}{ { - "name": "imdbId", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, - "description": "IMDB ID фильма", + "name": "imdbId", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, + "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{}{ @@ -1329,4 +1377,4 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, } -} \ No newline at end of file +} diff --git a/pkg/services/auth.go b/pkg/services/auth.go index 71df6b6..b7300d4 100644 --- a/pkg/services/auth.go +++ b/pkg/services/auth.go @@ -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) } @@ -350,4 +265,77 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[ "success": true, "message": "Verification code sent to your email", }, nil -} \ No newline at end of file +} + +// 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 +} diff --git a/pkg/services/torrent.go b/pkg/services/torrent.go index a9d5677..66443a0 100644 --- a/pkg/services/torrent.go +++ b/pkg/services/torrent.go @@ -32,7 +32,7 @@ func NewTorrentService() *TorrentService { // SearchTorrents - основной метод поиска торрентов через RedAPI func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) { searchParams := url.Values{} - + // Добавляем все параметры поиска for key, value := range params { if value != "" { @@ -43,13 +43,13 @@ func (s *TorrentService) SearchTorrents(params map[string]string) (*models.Torre } } } - + 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) @@ -67,7 +67,7 @@ func (s *TorrentService) SearchTorrents(params map[string]string) (*models.Torre } results := s.parseRedAPIResults(redAPIResponse) - + return &models.TorrentSearchResponse{ Query: params["query"], Results: results, @@ -78,7 +78,7 @@ func (s *TorrentService) SearchTorrents(params map[string]string) (*models.Torre // parseRedAPIResults преобразует результаты RedAPI в наш формат func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models.TorrentResult { var results []models.TorrentResult - + for _, torrent := range data.Results { // Обрабатываем размер - может быть строкой или числом var sizeStr string @@ -92,7 +92,7 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models default: sizeStr = "" } - + result := models.TorrentResult{ Title: torrent.Title, Tracker: torrent.Tracker, @@ -105,7 +105,7 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models Details: torrent.Details, Source: "RedAPI", } - + // Добавляем информацию из Info если она есть if torrent.Info != nil { // Обрабатываем качество - может быть строкой или числом @@ -117,56 +117,55 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models 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 + // ИСПРАВЛЕНО: Теперь присваиваются все 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) + } + 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{ @@ -196,15 +223,15 @@ func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*model "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 } @@ -304,15 +331,15 @@ func (s *TorrentService) SearchAnime(title, originalTitle, year string) (*models "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 } @@ -331,7 +358,7 @@ type AllohaResponse struct { 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 @@ -377,7 +404,7 @@ func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, medi // Если 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 @@ -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 } @@ -579,7 +602,7 @@ func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int) return true } } - + // Проверяем в названии seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`) matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1) @@ -594,14 +617,14 @@ func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int) return true } } - + return false } // ExtractQuality - извлечение качества из названия func (s *TorrentService) ExtractQuality(title string) string { title = strings.ToUpper(title) - + qualityPatterns := []struct { pattern string quality string @@ -613,7 +636,7 @@ func (s *TorrentService) ExtractQuality(title string) string { {`480P`, "480p"}, {`360P`, "360p"}, } - + for _, qp := range qualityPatterns { if matched, _ := regexp.MatchString(qp.pattern, title); matched { if qp.quality == "2160p" { @@ -622,7 +645,7 @@ func (s *TorrentService) ExtractQuality(title string) string { return qp.quality } } - + return "Unknown" } @@ -637,14 +660,16 @@ func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, s 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 + 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 } @@ -661,43 +686,43 @@ func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, s // 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) @@ -712,7 +737,7 @@ func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[strin seasons[seasonNumber] = true } } - + // Если сезоны не найдены, добавляем в группу "unknown" if len(seasons) == 0 { groups["Неизвестно"] = append(groups["Неизвестно"], torrent) @@ -734,14 +759,14 @@ func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[strin } } } - + // Сортируем торренты внутри каждой группы по сидам for season := range groups { sort.Slice(groups[season], func(i, j int) bool { return groups[season][i].Seeders > groups[season][j].Seeders }) } - + return groups } @@ -751,15 +776,15 @@ func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string) 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) @@ -775,25 +800,99 @@ func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string) } } } - + var seasons []int for season := range seasonsSet { seasons = append(seasons, season) } - + sort.Ints(seasons) 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 } @@ -801,21 +900,32 @@ 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)] - + + 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 } } @@ -829,4 +939,4 @@ func (s *TorrentService) containsAny(slice []string, items []string) bool { } } return false -} \ No newline at end of file +}