diff --git a/.env.example b/.env.example index c040da8..961781c 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,28 @@ -# MongoDB Configuration -MONGO_URI=mongodb://localhost:27017/neomovies +# Required +MONGO_URI= +MONGO_DB_NAME=database +TMDB_ACCESS_TOKEN= +JWT_SECRET= -# TMDB API Configuration -TMDB_ACCESS_TOKEN=your_tmdb_access_token_here - -# JWT Configuration -JWT_SECRET=your_super_secret_jwt_key_here - -# Email Configuration (для уведомлений) -GMAIL_USER=your_gmail@gmail.com -GMAIL_APP_PASSWORD=your_app_specific_password - -# Players Configuration -LUMEX_URL=your_lumex_player_url -ALLOHA_TOKEN=your_alloha_token - -# Server Configuration +# Service PORT=3000 BASE_URL=http://localhost:3000 NODE_ENV=development -# Production Configuration (для Vercel) -# MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/neomovies -# BASE_URL=https://your-app.vercel.app -# NODE_ENV=production \ No newline at end of file +# Email (Gmail) +GMAIL_USER= +GMAIL_APP_PASSWORD= + +# Players +LUMEX_URL= +ALLOHA_TOKEN= + +# Torrents (RedAPI) +REDAPI_BASE_URL=http://redapi.cfhttp.top +REDAPI_KEY= + +# Google OAuth +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback +FRONTEND_URL=http://localhost:3001 \ No newline at end of file diff --git a/README.md b/README.md index ee6b357..8471dc9 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 документация +- Поддержка русского языка ## 🛠 Быстрый старт @@ -50,7 +18,7 @@ 1. **Клонирование репозитория** ```bash -git clone +git clone https://gitlab.com/foxixus/neomovies-api.git cd neomovies-api ``` @@ -82,22 +50,33 @@ API будет доступен на `http://localhost:3000` ```bash # Обязательные -MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/neomovies -TMDB_ACCESS_TOKEN=your_tmdb_access_token_here -JWT_SECRET=your_super_secret_jwt_key_here +MONGO_URI= +MONGO_DB_NAME=database +TMDB_ACCESS_TOKEN= +JWT_SECRET= -# Для email уведомлений (Gmail) -GMAIL_USER=your_gmail@gmail.com -GMAIL_APP_PASSWORD=your_app_specific_password - -# Для плееров -LUMEX_URL=your_lumex_player_url -ALLOHA_TOKEN=your_alloha_token - -# Автоматические (Vercel) +# Сервис PORT=3000 -BASE_URL=https://api.neomovies.ru -NODE_ENV=production +BASE_URL=http://localhost:3000 +NODE_ENV=development + +# Email (Gmail) +GMAIL_USER= +GMAIL_APP_PASSWORD= + +# Плееры +LUMEX_URL= +ALLOHA_TOKEN= +VIBIX_TOKEN= + +# Торренты (RedAPI) +REDAPI_BASE_URL=http://redapi.cfhttp.top +REDAPI_KEY= + +# Google OAuth +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback ``` ## 📋 API Endpoints @@ -113,6 +92,8 @@ POST /api/v1/auth/register # Регистрация (отпр POST /api/v1/auth/verify # Подтверждение email кодом POST /api/v1/auth/resend-code # Повторная отправка кода POST /api/v1/auth/login # Авторизация +GET /api/v1/auth/google/login # Начало авторизации через Google (redirect) +GET /api/v1/auth/google/callback # Коллбек Google OAuth (возвращает JWT) # Поиск и категории GET /search/multi # Мультипоиск @@ -140,14 +121,15 @@ GET /api/v1/tv/{id}/recommendations # Рекомендации GET /api/v1/tv/{id}/similar # Похожие # Плееры -GET /api/v1/players/alloha # Alloha плеер -GET /api/v1/players/lumex # Lumex плеер +GET /api/v1/players/alloha/{imdb_id} # Alloha плеер по IMDb ID +GET /api/v1/players/lumex/{imdb_id} # Lumex плеер по IMDb ID +GET /api/v1/players/vibix/{imdb_id} # Vibix плеер по IMDb ID # Торренты GET /api/v1/torrents/search/{imdbId} # Поиск торрентов # Реакции (публичные) -GET /api/v1/reactions/{type}/{id}/counts # Счетчики реакций +GET /api/v1/reactions/{mediaType}/{mediaId}/counts # Счетчики реакций # Изображения GET /api/v1/images/{size}/{path} # Прокси TMDB изображений @@ -166,10 +148,10 @@ POST /api/v1/favorites/{id} # Добавить в избран DELETE /api/v1/favorites/{id} # Удалить из избранного # Реакции (приватные) -GET /api/v1/reactions/{type}/{id}/my-reaction # Моя реакция -POST /api/v1/reactions/{type}/{id} # Установить реакцию -DELETE /api/v1/reactions/{type}/{id} # Удалить реакцию -GET /api/v1/reactions/my # Все мои реакции +GET /api/v1/reactions/{mediaType}/{mediaId}/my-reaction # Моя реакция +POST /api/v1/reactions/{mediaType}/{mediaId} # Установить реакцию +DELETE /api/v1/reactions/{mediaType}/{mediaId} # Удалить реакцию +GET /api/v1/reactions/my # Все мои реакции ``` ## 📖 Примеры использования diff --git a/api/index.go b/api/index.go index 8059bf2..f8b7a67 100644 --- a/api/index.go +++ b/api/index.go @@ -25,17 +25,14 @@ var ( ) func initializeApp() { - // Загружаем переменные окружения (в Vercel они уже установлены) if err := godotenv.Load(); err != nil { - log.Println("Warning: .env file not found (normal for Vercel)") + _ = err } - // Инициализируем конфигурацию globalCfg = config.New() - // Подключаемся к базе данных var err error - globalDB, err = database.Connect(globalCfg.MongoURI) + globalDB, err = database.Connect(globalCfg.MongoURI, globalCfg.MongoDBName) if err != nil { log.Printf("Failed to connect to database: %v", err) initError = err @@ -46,29 +43,28 @@ func initializeApp() { } func Handler(w http.ResponseWriter, r *http.Request) { - // Инициализируем приложение один раз initOnce.Do(initializeApp) - // Проверяем, была ли ошибка инициализации if initError != nil { log.Printf("Initialization error: %v", initError) http.Error(w, "Application initialization failed: "+initError.Error(), http.StatusInternalServerError) return } - // Инициализируем сервисы tmdbService := services.NewTMDBService(globalCfg.TMDBAccessToken) emailService := services.NewEmailService(globalCfg) - authService := services.NewAuthService(globalDB, globalCfg.JWTSecret, emailService) + authService := services.NewAuthService(globalDB, globalCfg.JWTSecret, emailService, globalCfg.BaseURL, globalCfg.GoogleClientID, globalCfg.GoogleClientSecret, globalCfg.GoogleRedirectURL, globalCfg.FrontendURL) + movieService := services.NewMovieService(globalDB, tmdbService) tvService := services.NewTVService(globalDB, tmdbService) - torrentService := services.NewTorrentService() + favoritesService := services.NewFavoritesService(globalDB, tmdbService) + torrentService := services.NewTorrentServiceWithConfig(globalCfg.RedAPIBaseURL, globalCfg.RedAPIKey) reactionsService := services.NewReactionsService(globalDB) - // Создаем обработчики authHandler := handlersPkg.NewAuthHandler(authService) movieHandler := handlersPkg.NewMovieHandler(movieService) tvHandler := handlersPkg.NewTVHandler(tvService) + favoritesHandler := handlersPkg.NewFavoritesHandler(favoritesService, globalCfg) docsHandler := handlersPkg.NewDocsHandler() searchHandler := handlersPkg.NewSearchHandler(tmdbService) categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService) @@ -77,31 +73,27 @@ func Handler(w http.ResponseWriter, r *http.Request) { reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService) imagesHandler := handlersPkg.NewImagesHandler() - // Настраиваем маршруты router := mux.NewRouter() - // Документация API на корневом пути router.HandleFunc("/", docsHandler.ServeDocs).Methods("GET") router.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET") - // API маршруты api := router.PathPrefix("/api/v1").Subrouter() - // Публичные маршруты api.HandleFunc("/health", handlersPkg.HealthCheck).Methods("GET") api.HandleFunc("/auth/register", authHandler.Register).Methods("POST") api.HandleFunc("/auth/login", authHandler.Login).Methods("POST") api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST") api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).Methods("POST") + api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET") + api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET") - // Поиск - router.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET") + api.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET") - // Категории api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET") api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET") + api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).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/rgshows/{tmdb_id}", playersHandler.GetRgShowsPlayer).Methods("GET") @@ -109,16 +101,17 @@ func Handler(w http.ResponseWriter, r *http.Request) { api.HandleFunc("/players/iframevideo/{kinopoisk_id}/{imdb_id}", playersHandler.GetIframeVideoPlayer).Methods("GET") api.HandleFunc("/stream/{provider}/{tmdb_id}", playersHandler.GetStreamAPI).Methods("GET") - // Торренты api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") + api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") + api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET") + api.HandleFunc("/torrents/anime", torrentsHandler.SearchAnime).Methods("GET") + api.HandleFunc("/torrents/seasons", torrentsHandler.GetAvailableSeasons).Methods("GET") + api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET") - // Реакции (публичные) api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET") - // Изображения (прокси для 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") @@ -129,7 +122,6 @@ func Handler(w http.ResponseWriter, r *http.Request) { 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/popular", tvHandler.Popular).Methods("GET") api.HandleFunc("/tv/top-rated", tvHandler.TopRated).Methods("GET") @@ -140,26 +132,23 @@ func Handler(w http.ResponseWriter, r *http.Request) { api.HandleFunc("/tv/{id}/similar", tvHandler.GetSimilar).Methods("GET") api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET") - // Приватные маршруты (требуют авторизации) protected := api.PathPrefix("").Subrouter() protected.Use(middleware.JWTAuth(globalCfg.JWTSecret)) - // Избранное - protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET") - protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST") - protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE") + protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET") + protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST") + protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE") + protected.HandleFunc("/favorites/{id}/check", favoritesHandler.CheckIsFavorite).Methods("GET") - // Пользовательские данные protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET") protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") + protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE") - // Реакции (приватные) protected.HandleFunc("/reactions/{mediaType}/{mediaId}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE") protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET") - // CORS middleware corsHandler := handlers.CORS( handlers.AllowedOrigins([]string{"*"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), @@ -167,6 +156,5 @@ func Handler(w http.ResponseWriter, r *http.Request) { handlers.AllowCredentials(), ) - // Обрабатываем запрос corsHandler(router).ServeHTTP(w, r) } diff --git a/go.mod b/go.mod index 506818f..1c7925f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module neomovies-api -go 1.22.0 +go 1.23.0 toolchain go1.24.2 @@ -13,9 +13,11 @@ require ( github.com/joho/godotenv v1.5.1 go.mongodb.org/mongo-driver v1.11.6 golang.org/x/crypto v0.17.0 + golang.org/x/oauth2 v0.30.0 ) require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect github.com/golang/snappy v0.0.1 // indirect github.com/klauspost/compress v1.13.6 // indirect diff --git a/go.sum b/go.sum index be9c95d..99e68b2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM= github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06/go.mod h1:/wotfjM8I3m8NuIHPz3S8k+CCYH80EqDT8ZeNLqMQm0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -51,6 +53,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/main.go b/main.go index 15b90a1..58bf16b 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main import ( - "log" + "fmt" "net/http" "os" @@ -13,38 +13,38 @@ 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") + _ = err } - // Инициализируем конфигурацию cfg := config.New() - // Подключаемся к базе данных - db, err := database.Connect(cfg.MongoURI) + db, err := database.Connect(cfg.MongoURI, cfg.MongoDBName) 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, cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.GoogleRedirectURL, cfg.FrontendURL) + movieService := services.NewMovieService(db, tmdbService) tvService := services.NewTVService(db, tmdbService) - torrentService := services.NewTorrentService() + favoritesService := services.NewFavoritesService(db, tmdbService) + torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey) reactionsService := services.NewReactionsService(db) - // Создаем обработчики authHandler := appHandlers.NewAuthHandler(authService) movieHandler := appHandlers.NewMovieHandler(movieService) tvHandler := appHandlers.NewTVHandler(tvService) + favoritesHandler := appHandlers.NewFavoritesHandler(favoritesService, cfg) docsHandler := appHandlers.NewDocsHandler() searchHandler := appHandlers.NewSearchHandler(tmdbService) categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService) @@ -53,35 +53,32 @@ func main() { reactionsHandler := appHandlers.NewReactionsHandler(reactionsService) imagesHandler := appHandlers.NewImagesHandler() - // Настраиваем маршруты r := mux.NewRouter() - // Документация API на корневом пути r.HandleFunc("/", docsHandler.ServeDocs).Methods("GET") r.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET") - // API маршруты api := r.PathPrefix("/api/v1").Subrouter() - // Публичные маршруты api.HandleFunc("/health", appHandlers.HealthCheck).Methods("GET") api.HandleFunc("/auth/register", authHandler.Register).Methods("POST") api.HandleFunc("/auth/login", authHandler.Login).Methods("POST") api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST") api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).Methods("POST") + api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET") + api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET") + api.HandleFunc("/auth/refresh", authHandler.RefreshToken).Methods("POST") - // Поиск - r.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET") + api.HandleFunc("/search/multi", searchHandler.MultiSearch).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") + api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).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/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET") + api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") + api.HandleFunc("/players/vibix/{imdb_id}", playersHandler.GetVibixPlayer).Methods("GET") - // Торренты api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET") @@ -89,13 +86,10 @@ func main() { api.HandleFunc("/torrents/seasons", torrentsHandler.GetAvailableSeasons).Methods("GET") api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET") - // Реакции (публичные) api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET") - // Изображения (прокси для 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,8 +98,8 @@ 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") api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET") api.HandleFunc("/tv/top-rated", tvHandler.TopRated).Methods("GET") @@ -114,42 +108,59 @@ 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() protected.Use(middleware.JWTAuth(cfg.JWTSecret)) - // Избранное - protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET") - protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST") - protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE") + protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET") + protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST") + protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE") + protected.HandleFunc("/favorites/{id}/check", favoritesHandler.CheckIsFavorite).Methods("GET") - // Пользовательские данные protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET") protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") + protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE") + protected.HandleFunc("/auth/revoke-token", authHandler.RevokeRefreshToken).Methods("POST") + protected.HandleFunc("/auth/revoke-all-tokens", authHandler.RevokeAllRefreshTokens).Methods("POST") - // Реакции (приватные) protected.HandleFunc("/reactions/{mediaType}/{mediaId}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE") protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET") - // 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", "X-CSRF-Token"}), + handlers.AllowCredentials(), + handlers.ExposedHeaders([]string{"Authorization", "Content-Type"}), ) - // Определяем порт - port := os.Getenv("PORT") + var finalHandler http.Handler + if cfg.NodeEnv == "development" { + 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 := 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/config/config.go b/pkg/config/config.go index 22bfea6..b05d585 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,50 +6,60 @@ import ( ) type Config struct { - MongoURI string - TMDBAccessToken string - JWTSecret string - Port string - BaseURL string - NodeEnv string - GmailUser string - GmailPassword string - LumexURL string - AllohaToken string + MongoURI string + MongoDBName string + TMDBAccessToken string + JWTSecret string + Port string + BaseURL string + NodeEnv string + GmailUser string + GmailPassword string + LumexURL string + AllohaToken string + RedAPIBaseURL string + RedAPIKey string + GoogleClientID string + GoogleClientSecret string + GoogleRedirectURL string + FrontendURL string + VibixHost string + VibixToken string } func New() *Config { - // Добавляем отладочное логирование для Vercel mongoURI := getMongoURI() - log.Printf("DEBUG: MongoDB URI configured (length: %d)", len(mongoURI)) - + return &Config{ - MongoURI: mongoURI, - TMDBAccessToken: getEnv("TMDB_ACCESS_TOKEN", ""), - JWTSecret: getEnv("JWT_SECRET", "your-secret-key"), - Port: getEnv("PORT", "3000"), - BaseURL: getEnv("BASE_URL", "http://localhost:3000"), - NodeEnv: getEnv("NODE_ENV", "development"), - GmailUser: getEnv("GMAIL_USER", ""), - GmailPassword: getEnv("GMAIL_APP_PASSWORD", ""), - LumexURL: getEnv("LUMEX_URL", ""), - AllohaToken: getEnv("ALLOHA_TOKEN", ""), + MongoURI: mongoURI, + MongoDBName: getEnv(EnvMongoDBName, DefaultMongoDBName), + TMDBAccessToken: getEnv(EnvTMDBAccessToken, ""), + JWTSecret: getEnv(EnvJWTSecret, DefaultJWTSecret), + Port: getEnv(EnvPort, DefaultPort), + BaseURL: getEnv(EnvBaseURL, DefaultBaseURL), + NodeEnv: getEnv(EnvNodeEnv, DefaultNodeEnv), + GmailUser: getEnv(EnvGmailUser, ""), + GmailPassword: getEnv(EnvGmailPassword, ""), + LumexURL: getEnv(EnvLumexURL, ""), + AllohaToken: getEnv(EnvAllohaToken, ""), + RedAPIBaseURL: getEnv(EnvRedAPIBaseURL, DefaultRedAPIBase), + RedAPIKey: getEnv(EnvRedAPIKey, ""), + GoogleClientID: getEnv(EnvGoogleClientID, ""), + GoogleClientSecret: getEnv(EnvGoogleClientSecret, ""), + GoogleRedirectURL: getEnv(EnvGoogleRedirectURL, ""), + FrontendURL: getEnv(EnvFrontendURL, ""), + VibixHost: getEnv(EnvVibixHost, DefaultVibixHost), + VibixToken: getEnv(EnvVibixToken, ""), } } -// getMongoURI проверяет различные варианты названий переменных для MongoDB URI func getMongoURI() string { - // Проверяем различные возможные названия переменных - envVars := []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} - - for _, envVar := range envVars { + for _, envVar := range []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} { if value := os.Getenv(envVar); value != "" { log.Printf("DEBUG: Using %s for MongoDB connection", envVar) return value } } - - // Если ни одна переменная не найдена, возвращаем пустую строку log.Printf("DEBUG: No MongoDB URI environment variable found") return "" } @@ -59,4 +69,4 @@ func getEnv(key, defaultValue string) string { return value } return defaultValue -} \ No newline at end of file +} diff --git a/pkg/config/vars.go b/pkg/config/vars.go new file mode 100644 index 0000000..470f2ca --- /dev/null +++ b/pkg/config/vars.go @@ -0,0 +1,36 @@ +package config + +const ( + // Environment variable keys + EnvTMDBAccessToken = "TMDB_ACCESS_TOKEN" + EnvJWTSecret = "JWT_SECRET" + EnvPort = "PORT" + EnvBaseURL = "BASE_URL" + EnvNodeEnv = "NODE_ENV" + EnvGmailUser = "GMAIL_USER" + EnvGmailPassword = "GMAIL_APP_PASSWORD" + EnvLumexURL = "LUMEX_URL" + EnvAllohaToken = "ALLOHA_TOKEN" + EnvRedAPIBaseURL = "REDAPI_BASE_URL" + EnvRedAPIKey = "REDAPI_KEY" + EnvMongoDBName = "MONGO_DB_NAME" + EnvGoogleClientID = "GOOGLE_CLIENT_ID" + EnvGoogleClientSecret = "GOOGLE_CLIENT_SECRET" + EnvGoogleRedirectURL = "GOOGLE_REDIRECT_URL" + EnvFrontendURL = "FRONTEND_URL" + EnvVibixHost = "VIBIX_HOST" + EnvVibixToken = "VIBIX_TOKEN" + + // Default values + DefaultJWTSecret = "your-secret-key" + DefaultPort = "3000" + DefaultBaseURL = "http://localhost:3000" + DefaultNodeEnv = "development" + DefaultRedAPIBase = "http://redapi.cfhttp.top" + DefaultMongoDBName = "database" + DefaultVibixHost = "https://vibix.org" + + // Static constants + TMDBImageBaseURL = "https://image.tmdb.org/t/p" + CubAPIBaseURL = "https://cub.rip/api" +) diff --git a/pkg/database/connection.go b/pkg/database/connection.go index f88452e..89b40a0 100644 --- a/pkg/database/connection.go +++ b/pkg/database/connection.go @@ -10,7 +10,7 @@ import ( var client *mongo.Client -func Connect(uri string) (*mongo.Database, error) { +func Connect(uri, dbName string) (*mongo.Database, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -20,13 +20,11 @@ func Connect(uri string) (*mongo.Database, error) { return nil, err } - // Проверяем соединение - err = client.Ping(ctx, nil) - if err != nil { + if err = client.Ping(ctx, nil); err != nil { return nil, err } - return client.Database("database"), nil + return client.Database(dbName), nil } func Disconnect() error { @@ -40,6 +38,4 @@ func Disconnect() error { return client.Disconnect(ctx) } -func GetClient() *mongo.Client { - return client -} \ No newline at end of file +func GetClient() *mongo.Client { return client } diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index 8dfbc29..2eaa3ea 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -3,6 +3,8 @@ package handlers import ( "encoding/json" "net/http" + "strings" + "time" "go.mongodb.org/mongo-driver/bson" @@ -16,9 +18,7 @@ type AuthHandler struct { } func NewAuthHandler(authService *services.AuthService) *AuthHandler { - return &AuthHandler{ - authService: authService, - } + return &AuthHandler{authService: authService} } func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { @@ -36,11 +36,7 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: response, - Message: "User registered successfully", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "User registered successfully"}) } func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { @@ -50,23 +46,91 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { return } - response, err := h.authService.Login(req) + // Получаем информацию о клиенте для refresh токена + userAgent := r.Header.Get("User-Agent") + ipAddress := r.RemoteAddr + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + ipAddress = forwarded + } + + response, err := h.authService.LoginWithTokens(req, userAgent, ipAddress) if err != nil { - // Определяем правильный статус код в зависимости от ошибки statusCode := http.StatusBadRequest if err.Error() == "Account not activated. Please verify your email." { - statusCode = http.StatusForbidden // 403 для неверифицированного email + statusCode = http.StatusForbidden } http.Error(w, err.Error(), statusCode) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: response, - Message: "Login successful", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "Login successful"}) +} + +func (h *AuthHandler) GoogleLogin(w http.ResponseWriter, r *http.Request) { + state := generateState() + http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Path: "/", Expires: time.Now().Add(10 * time.Minute)}) + url, err := h.authService.GetGoogleLoginURL(state) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, url, http.StatusFound) +} + +func (h *AuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + state := q.Get("state") + code := q.Get("code") + preferJSON := q.Get("response") == "json" || strings.Contains(r.Header.Get("Accept"), "application/json") + cookie, _ := r.Cookie("oauth_state") + if cookie == nil || cookie.Value != state || code == "" { + if preferJSON { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: "invalid oauth state"}) + return + } + redirectURL, ok := h.authService.BuildFrontendRedirect("", "invalid_state") + if ok { + http.Redirect(w, r, redirectURL, http.StatusFound) + return + } + http.Error(w, "invalid oauth state", http.StatusBadRequest) + return + } + + resp, err := h.authService.HandleGoogleCallback(r.Context(), code) + if err != nil { + if preferJSON { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: err.Error()}) + return + } + redirectURL, ok := h.authService.BuildFrontendRedirect("", "auth_failed") + if ok { + http.Redirect(w, r, redirectURL, http.StatusFound) + return + } + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if preferJSON { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"}) + return + } + + redirectURL, ok := h.authService.BuildFrontendRedirect(resp.Token, "") + if ok { + http.Redirect(w, r, redirectURL, http.StatusFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"}) } func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) { @@ -83,10 +147,7 @@ func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: user, - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user}) } func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { @@ -102,7 +163,6 @@ func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { return } - // Удаляем поля, которые нельзя обновлять через этот эндпоинт delete(updates, "password") delete(updates, "email") delete(updates, "_id") @@ -115,14 +175,25 @@ func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: user, - Message: "Profile updated successfully", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user, Message: "Profile updated successfully"}) +} + +func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserIDFromContext(r.Context()) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + if err := h.authService.DeleteAccount(r.Context(), userID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Account deleted successfully"}) } -// Верификация email func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { var req models.VerifyEmailRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -140,7 +211,6 @@ func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } -// Повторная отправка кода верификации func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) { var req models.ResendCodeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -156,4 +226,84 @@ func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Requ w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) -} \ No newline at end of file +} + +// RefreshToken refreshes an access token using a refresh token +func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) { + var req models.RefreshTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Получаем информацию о клиенте + userAgent := r.Header.Get("User-Agent") + ipAddress := r.RemoteAddr + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + ipAddress = forwarded + } + + tokenPair, err := h.authService.RefreshAccessToken(req.RefreshToken, userAgent, ipAddress) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Data: tokenPair, + Message: "Token refreshed successfully", + }) +} + +// RevokeRefreshToken revokes a specific refresh token +func (h *AuthHandler) RevokeRefreshToken(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserIDFromContext(r.Context()) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + var req models.RefreshTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + err := h.authService.RevokeRefreshToken(userID, req.RefreshToken) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Message: "Refresh token revoked successfully", + }) +} + +// RevokeAllRefreshTokens revokes all refresh tokens for the current user +func (h *AuthHandler) RevokeAllRefreshTokens(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserIDFromContext(r.Context()) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + err := h.authService.RevokeAllRefreshTokens(userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Message: "All refresh tokens revoked successfully", + }) +} + +// helpers +func generateState() string { return uuidNew() } diff --git a/pkg/handlers/auth_helpers.go b/pkg/handlers/auth_helpers.go new file mode 100644 index 0000000..fd6f42e --- /dev/null +++ b/pkg/handlers/auth_helpers.go @@ -0,0 +1,7 @@ +package handlers + +import ( + "github.com/google/uuid" +) + +func uuidNew() string { return uuid.New().String() } diff --git a/pkg/handlers/categories.go b/pkg/handlers/categories.go index 3a86411..d8098c1 100644 --- a/pkg/handlers/categories.go +++ b/pkg/handlers/categories.go @@ -53,7 +53,7 @@ func (h *CategoriesHandler) GetCategories(w http.ResponseWriter, r *http.Request }) } -func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) { +func (h *CategoriesHandler) GetMediaByCategory(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) categoryID, err := strconv.Atoi(vars["id"]) if err != nil { @@ -67,20 +67,46 @@ func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.R language = "ru-RU" } - // Используем discover API для получения фильмов по жанру - movies, err := h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + mediaType := r.URL.Query().Get("type") + if mediaType == "" { + mediaType = "movie" // По умолчанию фильмы для обратной совместимости + } + + if mediaType != "movie" && mediaType != "tv" { + http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest) + return + } + + var data interface{} + var err2 error + + if mediaType == "movie" { + // Используем discover API для получения фильмов по жанру + data, err2 = h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language) + } else { + // Используем discover API для получения сериалов по жанру + data, err2 = h.tmdbService.DiscoverTVByGenre(categoryID, page, language) + } + + if err2 != nil { + http.Error(w, err2.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(models.APIResponse{ Success: true, - Data: movies, + Data: data, + Message: "Media retrieved successfully", }) } +// Старый метод для обратной совместимости +func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) { + // Просто перенаправляем на новый метод + h.GetMediaByCategory(w, r) +} + func generateSlug(name string) string { // Простая функция для создания slug из названия // В реальном проекте стоит использовать более сложную логику @@ -93,4 +119,4 @@ func generateSlug(name string) string { } } return result -} \ No newline at end of file +} diff --git a/pkg/handlers/docs.go b/pkg/handlers/docs.go index 3265436..1aec0d2 100644 --- a/pkg/handlers/docs.go +++ b/pkg/handlers/docs.go @@ -4,22 +4,18 @@ import ( "encoding/json" "fmt" "net/http" - "os" + "strings" "github.com/MarceloPetrucio/go-scalar-api-reference" ) -type DocsHandler struct { - // Убираем статическую спецификацию -} +type DocsHandler struct{} func NewDocsHandler() *DocsHandler { return &DocsHandler{} } func (h *DocsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Обслуживаем документацию для всех путей - // Это нужно для правильной работы Scalar API Reference h.ServeDocs(w, r) } @@ -28,34 +24,22 @@ func (h *DocsHandler) RedirectToDocs(w http.ResponseWriter, r *http.Request) { } func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) { - // Определяем baseURL динамически - baseURL := os.Getenv("BASE_URL") - if baseURL == "" { - if r.TLS != nil { - baseURL = fmt.Sprintf("https://%s", r.Host) - } else { - baseURL = fmt.Sprintf("http://%s", r.Host) - } - } + _ = determineBaseURL(r) - // Генерируем спецификацию с правильным URL - spec := getOpenAPISpecWithURL(baseURL) + // Use relative server URL to inherit correct scheme/host from the browser/proxy + spec := getOpenAPISpecWithURL("/") w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With") json.NewEncoder(w).Encode(spec) } func (h *DocsHandler) ServeDocs(w http.ResponseWriter, r *http.Request) { - baseURL := os.Getenv("BASE_URL") - if baseURL == "" { - if r.TLS != nil { - baseURL = fmt.Sprintf("https://%s", r.Host) - } else { - baseURL = fmt.Sprintf("http://%s", r.Host) - } - } + baseURL := determineBaseURL(r) + // Use absolute SpecURL so the library does not try to read a local file path htmlContent, err := scalar.ApiReferenceHTML(&scalar.Options{ SpecURL: fmt.Sprintf("%s/openapi.json", baseURL), CustomOptions: scalar.CustomOptions{ @@ -70,15 +54,70 @@ func (h *DocsHandler) ServeDocs(w http.ResponseWriter, r *http.Request) { return } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", "*") fmt.Fprintln(w, htmlContent) } +func determineBaseURL(r *http.Request) string { + // Prefer proxy headers and request info over environment to avoid wrong scheme on platforms like Vercel + proto := "" + host := r.Host + + if fwd := r.Header.Get("Forwarded"); fwd != "" { + for _, part := range strings.Split(fwd, ";") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + continue + } + key := strings.ToLower(strings.TrimSpace(kv[0])) + val := strings.Trim(strings.TrimSpace(kv[1]), "\"") + switch key { + case "proto": + if proto == "" { + proto = strings.ToLower(val) + } + case "host": + if val != "" { + host = val + } + } + } + } + + if proto == "" { + if p := r.Header.Get("X-Forwarded-Proto"); p != "" { + proto = strings.ToLower(strings.TrimSpace(strings.Split(p, ",")[0])) + } + } + if xfh := r.Header.Get("X-Forwarded-Host"); xfh != "" { + host = strings.TrimSpace(strings.Split(xfh, ",")[0]) + } + + if proto == "" { + if r.TLS != nil { + proto = "https" + } else { + proto = "http" + } + } + + if xfp := r.Header.Get("X-Forwarded-Port"); xfp != "" && !strings.Contains(host, ":") { + isDefault := (proto == "http" && xfp == "80") || (proto == "https" && xfp == "443") + if !isDefault { + host = host + ":" + xfp + } + } + + return fmt.Sprintf("%s://%s", proto, host) +} + type OpenAPISpec struct { - OpenAPI string `json:"openapi"` - Info Info `json:"info"` - Servers []Server `json:"servers"` - Paths map[string]interface{} `json:"paths"` - Components Components `json:"components"` + OpenAPI string `json:"openapi"` + Info Info `json:"info"` + Servers []Server `json:"servers"` + Paths map[string]interface{} `json:"paths"` + Components Components `json:"components"` } type Info struct { @@ -130,9 +169,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { Paths: map[string]interface{}{ "/api/v1/health": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Health Check", + "summary": "Health Check", "description": "Проверка работоспособности API", - "tags": []string{"Health"}, + "tags": []string{"Health"}, "responses": map[string]interface{}{ "200": map[string]interface{}{ "description": "API работает корректно", @@ -147,23 +186,23 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, }, - "/search/multi": map[string]interface{}{ + "/api/v1/search/multi": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Мультипоиск", + "summary": "Мультипоиск", "description": "Поиск фильмов, сериалов и актеров", - "tags": []string{"Search"}, + "tags": []string{"Search"}, "parameters": []map[string]interface{}{ { - "name": "query", - "in": "query", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "query", + "in": "query", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Поисковый запрос", }, { - "name": "page", - "in": "query", - "schema": map[string]string{"type": "integer", "default": "1"}, + "name": "page", + "in": "query", + "schema": map[string]string{"type": "integer", "default": "1"}, "description": "Номер страницы", }, }, @@ -176,16 +215,16 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/categories": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить категории", + "summary": "Получить категории", "description": "Получение списка категорий фильмов", - "tags": []string{"Categories"}, + "tags": []string{"Categories"}, "responses": map[string]interface{}{ "200": map[string]interface{}{ "description": "Список категорий", "content": map[string]interface{}{ "application/json": map[string]interface{}{ "schema": map[string]interface{}{ - "type": "array", + "type": "array", "items": map[string]interface{}{"$ref": "#/components/schemas/Category"}, }, }, @@ -196,15 +235,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/categories/{id}/movies": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Фильмы по категории", + "summary": "Фильмы по категории", "description": "Получение фильмов по категории", - "tags": []string{"Categories"}, + "tags": []string{"Categories"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID категории", }, }, @@ -215,17 +254,69 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, }, - "/api/v1/players/alloha/{imdb_id}": map[string]interface{}{ + "/api/v1/categories/{id}/media": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Плеер Alloha", - "description": "Получение плеера Alloha по IMDb ID", - "tags": []string{"Players"}, + "summary": "Медиа по категории", + "description": "Получение фильмов или сериалов по категории", + "tags": []string{"Categories"}, "parameters": []map[string]interface{}{ { - "name": "imdb_id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, + "description": "ID категории", + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "string", + "enum": []string{"movie", "tv"}, + "default": "movie", + }, + "description": "Тип медиа: movie или tv", + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "integer", + "default": 1, + }, + "description": "Номер страницы", + }, + { + "name": "language", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "string", + "default": "ru-RU", + }, + "description": "Язык ответа", + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Медиа по категории", + }, + }, + }, + }, + "/api/v1/players/alloha/{imdb_id}": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Плеер Alloha", + "description": "Получение плеера Alloha по IMDb ID", + "tags": []string{"Players"}, + "parameters": []map[string]interface{}{ + { + "name": "imdb_id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "IMDb ID фильма", }, }, @@ -238,15 +329,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/players/lumex/{imdb_id}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Плеер Lumex", + "summary": "Плеер Lumex", "description": "Получение плеера Lumex по IMDb ID", - "tags": []string{"Players"}, + "tags": []string{"Players"}, "parameters": []map[string]interface{}{ { - "name": "imdb_id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "imdb_id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "IMDb ID фильма", }, }, @@ -257,18 +348,128 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, }, - "/api/v1/torrents/search/{imdbId}": map[string]interface{}{ + "/api/v1/players/vibix/{imdb_id}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Поиск торрентов", - "description": "Поиск торрентов по IMDB ID", - "tags": []string{"Torrents"}, + "summary": "Vibix плеер по IMDb ID", + "description": "Возвращает HTML-страницу с iframe Vibix для указанного IMDb ID", + "tags": []string{"Players"}, "parameters": []map[string]interface{}{ { - "name": "imdbId", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, - "description": "IMDB ID фильма", + "name": "imdb_id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, + "description": "IMDb ID, например tt0133093", + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "HTML со встроенным Vibix плеером", + "content": map[string]interface{}{ + "text/html": map[string]interface{}{}, + }, + }, + "404": map[string]interface{}{"description": "Фильм не найден"}, + "503": map[string]interface{}{"description": "VIBIX_TOKEN не настроен"}, + }, + }, + }, + "/api/v1/webtorrent/player": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "WebTorrent плеер", + "description": "Открытие WebTorrent плеера с магнет ссылкой. Плеер работает полностью на стороне клиента.", + "tags": []string{"WebTorrent"}, + "parameters": []map[string]interface{}{ + { + "name": "magnet", + "in": "query", + "required": false, + "schema": map[string]string{"type": "string"}, + "description": "Магнет ссылка торрента", + }, + { + "name": "X-Magnet-Link", + "in": "header", + "required": false, + "schema": map[string]string{"type": "string"}, + "description": "Магнет ссылка через заголовок (альтернативный способ)", + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "HTML страница с WebTorrent плеером", + "content": map[string]interface{}{ + "text/html": map[string]interface{}{ + "schema": map[string]string{"type": "string"}, + }, + }, + }, + "400": map[string]interface{}{ + "description": "Отсутствует магнет ссылка", + }, + }, + }, + }, + "/api/v1/webtorrent/metadata": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Метаданные медиа", + "description": "Получение метаданных фильма или сериала по названию для WebTorrent плеера", + "tags": []string{"WebTorrent"}, + "parameters": []map[string]interface{}{ + { + "name": "query", + "in": "query", + "required": true, + "schema": map[string]string{"type": "string"}, + "description": "Название для поиска (извлеченное из торрента)", + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Метаданные найдены", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "$ref": "#/components/schemas/WebTorrentMetadata", + }, + }, + }, + }, + "400": map[string]interface{}{ + "description": "Отсутствует параметр query", + }, + "404": map[string]interface{}{ + "description": "Метаданные не найдены", + }, + }, + }, + }, + "/api/v1/torrents/search/{imdbId}": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Поиск торрентов", + "description": "Поиск торрентов по IMDB ID", + "tags": []string{"Torrents"}, + "parameters": []map[string]interface{}{ + { + "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{}{ @@ -280,22 +481,22 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/reactions/{mediaType}/{mediaId}/counts": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Количество реакций", + "summary": "Количество реакций", "description": "Получение количества реакций для медиа", - "tags": []string{"Reactions"}, + "tags": []string{"Reactions"}, "parameters": []map[string]interface{}{ { - "name": "mediaType", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "mediaType", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Тип медиа (movie/tv)", }, { - "name": "mediaId", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "mediaId", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "ID медиа", }, }, @@ -315,22 +516,22 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/images/{size}/{path}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Изображения", + "summary": "Изображения", "description": "Прокси для изображений TMDB", - "tags": []string{"Images"}, + "tags": []string{"Images"}, "parameters": []map[string]interface{}{ { - "name": "size", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "size", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Размер изображения", }, { - "name": "path", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "path", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Путь к изображению", }, }, @@ -346,9 +547,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/auth/register": map[string]interface{}{ "post": map[string]interface{}{ - "summary": "Регистрация пользователя", + "summary": "Регистрация пользователя", "description": "Создание нового аккаунта пользователя", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "requestBody": map[string]interface{}{ "required": true, "content": map[string]interface{}{ @@ -386,7 +587,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "content": map[string]interface{}{ "application/json": map[string]interface{}{ "schema": map[string]interface{}{ - "type": "object", + "type": "object", "required": []string{"email", "code"}, "properties": map[string]interface{}{ "email": map[string]interface{}{ @@ -440,7 +641,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "content": map[string]interface{}{ "application/json": map[string]interface{}{ "schema": map[string]interface{}{ - "type": "object", + "type": "object", "required": []string{"email"}, "properties": map[string]interface{}{ "email": map[string]interface{}{ @@ -481,9 +682,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/auth/login": map[string]interface{}{ "post": map[string]interface{}{ - "summary": "Авторизация пользователя", + "summary": "Авторизация пользователя", "description": "Получение JWT токена для доступа к приватным эндпоинтам", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "requestBody": map[string]interface{}{ "required": true, "content": map[string]interface{}{ @@ -513,9 +714,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/auth/profile": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить профиль пользователя", + "summary": "Получить профиль пользователя", "description": "Получение информации о текущем пользователе", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, }, @@ -533,9 +734,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, "put": map[string]interface{}{ - "summary": "Обновить профиль пользователя", + "summary": "Обновить профиль пользователя", "description": "Обновление информации о пользователе", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, }, @@ -545,36 +746,66 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, }, + "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{}{ - "summary": "Поиск фильмов", + "summary": "Поиск фильмов", "description": "Поиск фильмов по названию с поддержкой фильтров", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "query", - "in": "query", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "query", + "in": "query", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Поисковый запрос", }, { - "name": "page", - "in": "query", - "schema": map[string]string{"type": "integer", "default": "1"}, + "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"}, + "name": "language", + "in": "query", + "schema": map[string]string{"type": "string", "default": "ru-RU"}, "description": "Язык ответа", }, { - "name": "year", - "in": "query", - "schema": map[string]string{"type": "integer"}, + "name": "year", + "in": "query", + "schema": map[string]string{"type": "integer"}, "description": "Год выпуска", }, }, @@ -594,18 +825,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/popular": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Популярные фильмы", + "summary": "Популярные фильмы", "description": "Получение списка популярных фильмов", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -618,20 +849,20 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/{id}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить фильм по ID", + "summary": "Получить фильм по ID", "description": "Подробная информация о фильме", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID фильма в TMDB", }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -651,79 +882,136 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/favorites": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить избранные фильмы", - "description": "Список избранных фильмов пользователя", - "tags": []string{"Favorites"}, + "summary": "Получить избранное", + "description": "Список избранных фильмов и сериалов пользователя", + "tags": []string{"Favorites"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, }, "responses": map[string]interface{}{ "200": map[string]interface{}{ - "description": "Список избранных фильмов", + "description": "Список избранного", }, }, }, }, "/api/v1/favorites/{id}": map[string]interface{}{ "post": map[string]interface{}{ - "summary": "Добавить в избранное", - "description": "Добавление фильма в избранное", - "tags": []string{"Favorites"}, + "summary": "Добавить в избранное", + "description": "Добавление фильма или сериала в избранное", + "tags": []string{"Favorites"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, }, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, - "description": "ID фильма", + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, + "description": "ID медиа", + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "string", + "enum": []string{"movie", "tv"}, + "default": "movie", + }, + "description": "Тип медиа: movie или tv", }, }, "responses": map[string]interface{}{ "200": map[string]interface{}{ - "description": "Фильм добавлен в избранное", + "description": "Добавлено в избранное", }, }, }, "delete": map[string]interface{}{ - "summary": "Удалить из избранного", - "description": "Удаление фильма из избранного", - "tags": []string{"Favorites"}, + "summary": "Удалить из избранного", + "description": "Удаление фильма или сериала из избранного", + "tags": []string{"Favorites"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, }, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, - "description": "ID фильма", + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, + "description": "ID медиа", + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "string", + "enum": []string{"movie", "tv"}, + "default": "movie", + }, + "description": "Тип медиа: movie или tv", }, }, "responses": map[string]interface{}{ "200": map[string]interface{}{ - "description": "Фильм удален из избранного", + "description": "Удалено из избранного", + }, + }, + }, + }, + "/api/v1/favorites/{id}/check": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Проверить избранное", + "description": "Проверка, находится ли медиа в избранном", + "tags": []string{"Favorites"}, + "security": []map[string][]string{ + {"bearerAuth": []string{}}, + }, + "parameters": []map[string]interface{}{ + { + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, + "description": "ID медиа", + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "string", + "enum": []string{"movie", "tv"}, + "default": "movie", + }, + "description": "Тип медиа: movie или tv", + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Статус избранного", }, }, }, }, "/api/v1/movies/top-rated": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Топ рейтинг фильмов", + "summary": "Топ рейтинг фильмов", "description": "Получение списка фильмов с высоким рейтингом", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -736,18 +1024,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/upcoming": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Скоро в прокате", + "summary": "Скоро в прокате", "description": "Получение списка фильмов, которые скоро выйдут в прокат", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -760,18 +1048,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/now-playing": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Сейчас в прокате", + "summary": "Сейчас в прокате", "description": "Получение списка фильмов, которые сейчас в прокате", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -784,25 +1072,25 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/{id}/recommendations": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Рекомендации фильмов", + "summary": "Рекомендации фильмов", "description": "Получение рекомендаций фильмов на основе выбранного", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID фильма в TMDB", }, { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -815,25 +1103,25 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/{id}/similar": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Похожие фильмы", + "summary": "Похожие фильмы", "description": "Получение похожих фильмов", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID фильма в TMDB", }, { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -846,26 +1134,28 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/search": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Поиск сериалов", + "summary": "Поиск сериалов", "description": "Поиск сериалов по названию", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "query", - "in": "query", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "query", + "in": "query", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Поисковый запрос", }, { - "name": "page", - "in": "query", - "schema": map[string]string{"type": "integer", "default": "1"}, + "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"}, + "name": "language", + "in": "query", + "schema": map[string]string{"type": "string", "default": "ru-RU"}, + "description": "Язык ответа", }, }, "responses": map[string]interface{}{ @@ -884,18 +1174,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/popular": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Популярные сериалы", + "summary": "Популярные сериалы", "description": "Получение списка популярных сериалов", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -908,18 +1198,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/top-rated": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Топ рейтинг сериалов", + "summary": "Топ рейтинг сериалов", "description": "Получение списка сериалов с высоким рейтингом", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -932,18 +1222,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/on-the-air": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "В эфире", + "summary": "В эфире", "description": "Получение списка сериалов, которые сейчас в эфире", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -956,18 +1246,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/airing-today": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Сегодня в эфире", + "summary": "Сегодня в эфире", "description": "Получение списка сериалов, которые выходят сегодня", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -980,20 +1270,20 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/{id}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить сериал по ID", + "summary": "Получить сериал по ID", "description": "Подробная информация о сериале", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID сериала в TMDB", }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1013,25 +1303,25 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/{id}/recommendations": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Рекомендации сериалов", + "summary": "Рекомендации сериалов", "description": "Получение рекомендаций сериалов на основе выбранного", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID сериала в TMDB", }, { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1044,25 +1334,25 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/{id}/similar": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Похожие сериалы", + "summary": "Похожие сериалы", "description": "Получение похожих сериалов", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID сериала в TMDB", }, { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1075,15 +1365,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/{id}/external-ids": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Внешние идентификаторы фильма", + "summary": "Внешние идентификаторы фильма", "description": "Получить внешние ID (IMDb, TVDB, Facebook и др.) для фильма по TMDB ID", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID фильма в TMDB", }, }, @@ -1103,15 +1393,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/{id}/external-ids": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Внешние идентификаторы сериала", + "summary": "Внешние идентификаторы сериала", "description": "Получить внешние ID (IMDb, TVDB, Facebook и др.) для сериала по TMDB ID", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID сериала в TMDB", }, }, @@ -1129,6 +1419,39 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, }, + "/api/v1/auth/google/login": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Google OAuth: начало", + "description": "Редирект на страницу авторизации Google", + "tags": []string{"Authentication"}, + "responses": map[string]interface{}{ + "302": map[string]interface{}{"description": "Redirect to Google"}, + "400": map[string]interface{}{"description": "OAuth не сконфигурирован"}, + }, + }, + }, + "/api/v1/auth/google/callback": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Google OAuth: коллбек", + "description": "Обработка кода авторизации и выдача JWT", + "tags": []string{"Authentication"}, + "parameters": []map[string]interface{}{ + {"name": "state", "in": "query", "required": true, "schema": map[string]string{"type": "string"}}, + {"name": "code", "in": "query", "required": true, "schema": map[string]string{"type": "string"}}, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Успешная авторизация через Google", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/AuthResponse"}, + }, + }, + }, + "400": map[string]interface{}{"description": "Неверный state или ошибка обмена кода"}, + }, + }, + }, }, Components: Components{ SecuritySchemes: map[string]SecurityScheme{ @@ -1143,42 +1466,42 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "type": "object", "properties": map[string]interface{}{ "success": map[string]string{"type": "boolean"}, - "data": map[string]string{"type": "object"}, + "data": map[string]string{"type": "object"}, "message": map[string]string{"type": "string"}, - "error": map[string]string{"type": "string"}, + "error": map[string]string{"type": "string"}, }, }, "RegisterRequest": map[string]interface{}{ - "type": "object", + "type": "object", "required": []string{"email", "password", "name"}, "properties": map[string]interface{}{ "email": map[string]interface{}{ - "type": "string", - "format": "email", + "type": "string", + "format": "email", "example": "user@example.com", }, "password": map[string]interface{}{ - "type": "string", + "type": "string", "minLength": 6, - "example": "password123", + "example": "password123", }, "name": map[string]interface{}{ - "type": "string", + "type": "string", "example": "Иван Иванов", }, }, }, "LoginRequest": map[string]interface{}{ - "type": "object", + "type": "object", "required": []string{"email", "password"}, "properties": map[string]interface{}{ "email": map[string]interface{}{ - "type": "string", - "format": "email", + "type": "string", + "format": "email", "example": "user@example.com", }, "password": map[string]interface{}{ - "type": "string", + "type": "string", "example": "password123", }, }, @@ -1187,26 +1510,26 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "type": "object", "properties": map[string]interface{}{ "token": map[string]string{"type": "string"}, - "user": map[string]interface{}{"$ref": "#/components/schemas/User"}, + "user": map[string]interface{}{"$ref": "#/components/schemas/User"}, }, }, "User": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "string"}, - "email": map[string]string{"type": "string"}, - "name": map[string]string{"type": "string"}, + "id": map[string]string{"type": "string"}, + "email": map[string]string{"type": "string"}, + "name": map[string]string{"type": "string"}, "avatar": map[string]string{"type": "string"}, "favorites": map[string]interface{}{ - "type": "array", + "type": "array", "items": map[string]string{"type": "string"}, }, "created_at": map[string]interface{}{ - "type": "string", + "type": "string", "format": "date-time", }, "updated_at": map[string]interface{}{ - "type": "string", + "type": "string", "format": "date-time", }, }, @@ -1214,17 +1537,17 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "Movie": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "integer"}, - "title": map[string]string{"type": "string"}, - "original_title": map[string]string{"type": "string"}, - "overview": map[string]string{"type": "string"}, - "poster_path": map[string]string{"type": "string"}, - "backdrop_path": map[string]string{"type": "string"}, - "release_date": map[string]string{"type": "string"}, - "vote_average": map[string]string{"type": "number"}, - "vote_count": map[string]string{"type": "integer"}, - "popularity": map[string]string{"type": "number"}, - "adult": map[string]string{"type": "boolean"}, + "id": map[string]string{"type": "integer"}, + "title": map[string]string{"type": "string"}, + "original_title": map[string]string{"type": "string"}, + "overview": map[string]string{"type": "string"}, + "poster_path": map[string]string{"type": "string"}, + "backdrop_path": map[string]string{"type": "string"}, + "release_date": map[string]string{"type": "string"}, + "vote_average": map[string]string{"type": "number"}, + "vote_count": map[string]string{"type": "integer"}, + "popularity": map[string]string{"type": "number"}, + "adult": map[string]string{"type": "boolean"}, "original_language": map[string]string{"type": "string"}, }, }, @@ -1233,30 +1556,30 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "properties": map[string]interface{}{ "page": map[string]string{"type": "integer"}, "results": map[string]interface{}{ - "type": "array", + "type": "array", "items": map[string]interface{}{"$ref": "#/components/schemas/Movie"}, }, - "total_pages": map[string]string{"type": "integer"}, + "total_pages": map[string]string{"type": "integer"}, "total_results": map[string]string{"type": "integer"}, }, }, "TVSeries": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "integer"}, - "name": map[string]string{"type": "string"}, - "original_name": map[string]string{"type": "string"}, - "overview": map[string]string{"type": "string"}, - "poster_path": map[string]string{"type": "string"}, - "backdrop_path": map[string]string{"type": "string"}, - "first_air_date": map[string]string{"type": "string"}, - "vote_average": map[string]string{"type": "number"}, - "vote_count": map[string]string{"type": "integer"}, - "popularity": map[string]string{"type": "number"}, - "original_language": map[string]string{"type": "string"}, - "number_of_seasons": map[string]string{"type": "integer"}, + "id": map[string]string{"type": "integer"}, + "name": map[string]string{"type": "string"}, + "original_name": map[string]string{"type": "string"}, + "overview": map[string]string{"type": "string"}, + "poster_path": map[string]string{"type": "string"}, + "backdrop_path": map[string]string{"type": "string"}, + "first_air_date": map[string]string{"type": "string"}, + "vote_average": map[string]string{"type": "number"}, + "vote_count": map[string]string{"type": "integer"}, + "popularity": map[string]string{"type": "number"}, + "original_language": map[string]string{"type": "string"}, + "number_of_seasons": map[string]string{"type": "integer"}, "number_of_episodes": map[string]string{"type": "integer"}, - "status": map[string]string{"type": "string"}, + "status": map[string]string{"type": "string"}, }, }, "TVSearchResponse": map[string]interface{}{ @@ -1264,69 +1587,134 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "properties": map[string]interface{}{ "page": map[string]string{"type": "integer"}, "results": map[string]interface{}{ - "type": "array", + "type": "array", "items": map[string]interface{}{"$ref": "#/components/schemas/TVSeries"}, }, - "total_pages": map[string]string{"type": "integer"}, + "total_pages": map[string]string{"type": "integer"}, "total_results": map[string]string{"type": "integer"}, }, }, "Category": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "integer"}, - "name": map[string]string{"type": "string"}, + "id": map[string]string{"type": "integer"}, + "name": map[string]string{"type": "string"}, "description": map[string]string{"type": "string"}, }, }, "Player": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "url": map[string]string{"type": "string"}, - "title": map[string]string{"type": "string"}, + "url": map[string]string{"type": "string"}, + "title": map[string]string{"type": "string"}, "quality": map[string]string{"type": "string"}, - "type": map[string]string{"type": "string"}, + "type": map[string]string{"type": "string"}, }, }, "Torrent": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "title": map[string]string{"type": "string"}, - "size": map[string]string{"type": "string"}, - "seeds": map[string]string{"type": "integer"}, - "peers": map[string]string{"type": "integer"}, + "title": map[string]string{"type": "string"}, + "size": map[string]string{"type": "string"}, + "seeds": map[string]string{"type": "integer"}, + "peers": map[string]string{"type": "integer"}, "magnet": map[string]string{"type": "string"}, - "hash": map[string]string{"type": "string"}, + "hash": map[string]string{"type": "string"}, }, }, "Reaction": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "type": map[string]string{"type": "string"}, + "type": map[string]string{"type": "string"}, "count": map[string]string{"type": "integer"}, }, }, "ReactionCounts": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "like": map[string]string{"type": "integer"}, + "like": map[string]string{"type": "integer"}, "dislike": map[string]string{"type": "integer"}, - "love": map[string]string{"type": "integer"}, + "love": map[string]string{"type": "integer"}, }, }, "ExternalIDs": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "integer"}, - "imdb_id": map[string]string{"type": "string"}, - "tvdb_id": map[string]string{"type": "integer"}, - "wikidata_id": map[string]string{"type": "string"}, - "facebook_id": map[string]string{"type": "string"}, + "id": map[string]string{"type": "integer"}, + "imdb_id": map[string]string{"type": "string"}, + "tvdb_id": map[string]string{"type": "integer"}, + "wikidata_id": map[string]string{"type": "string"}, + "facebook_id": map[string]string{"type": "string"}, "instagram_id": map[string]string{"type": "string"}, - "twitter_id": map[string]string{"type": "string"}, + "twitter_id": map[string]string{"type": "string"}, + }, + }, + "WebTorrentMetadata": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]string{"type": "integer"}, + "title": map[string]string{"type": "string"}, + "type": map[string]interface{}{ + "type": "string", + "enum": []string{"movie", "tv"}, + }, + "year": map[string]string{"type": "integer"}, + "posterPath": map[string]string{"type": "string"}, + "backdropPath": map[string]string{"type": "string"}, + "overview": map[string]string{"type": "string"}, + "runtime": map[string]string{"type": "integer"}, + "genres": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/Genre", + }, + }, + "seasons": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/SeasonMetadata", + }, + }, + "episodes": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/EpisodeMetadata", + }, + }, + }, + }, + "SeasonMetadata": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "seasonNumber": map[string]string{"type": "integer"}, + "name": map[string]string{"type": "string"}, + "episodes": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/EpisodeMetadata", + }, + }, + }, + }, + "EpisodeMetadata": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "episodeNumber": map[string]string{"type": "integer"}, + "seasonNumber": map[string]string{"type": "integer"}, + "name": map[string]string{"type": "string"}, + "overview": map[string]string{"type": "string"}, + "runtime": map[string]string{"type": "integer"}, + "stillPath": map[string]string{"type": "string"}, + }, + }, + "Genre": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]string{"type": "integer"}, + "name": map[string]string{"type": "string"}, }, }, }, }, } -} \ No newline at end of file +} diff --git a/pkg/handlers/favorites.go b/pkg/handlers/favorites.go new file mode 100644 index 0000000..7fc42f7 --- /dev/null +++ b/pkg/handlers/favorites.go @@ -0,0 +1,260 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/gorilla/mux" + + "neomovies-api/pkg/config" + "neomovies-api/pkg/middleware" + "neomovies-api/pkg/models" + "neomovies-api/pkg/services" +) + +type FavoritesHandler struct { + favoritesService *services.FavoritesService + config *config.Config +} + +func NewFavoritesHandler(favoritesService *services.FavoritesService, cfg *config.Config) *FavoritesHandler { + return &FavoritesHandler{ + favoritesService: favoritesService, + config: cfg, + } +} + +func (h *FavoritesHandler) GetFavorites(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserIDFromContext(r.Context()) + if !ok { + http.Error(w, "User ID not found in context", http.StatusUnauthorized) + return + } + + favorites, err := h.favoritesService.GetFavorites(userID) + if err != nil { + http.Error(w, "Failed to get favorites: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Data: favorites, + Message: "Favorites retrieved successfully", + }) +} + +func (h *FavoritesHandler) AddToFavorites(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserIDFromContext(r.Context()) + if !ok { + http.Error(w, "User ID not found in context", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + mediaID := vars["id"] + mediaType := r.URL.Query().Get("type") + + if mediaID == "" { + http.Error(w, "Media ID is required", http.StatusBadRequest) + return + } + + if mediaType == "" { + mediaType = "movie" // По умолчанию фильм для обратной совместимости + } + + if mediaType != "movie" && mediaType != "tv" { + http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest) + return + } + + // Получаем информацию о медиа на русском языке + mediaInfo, err := h.fetchMediaInfoRussian(mediaID, mediaType) + if err != nil { + http.Error(w, "Failed to fetch media information: "+err.Error(), http.StatusInternalServerError) + return + } + + err = h.favoritesService.AddToFavoritesWithInfo(userID, mediaID, mediaType, mediaInfo) + if err != nil { + http.Error(w, "Failed to add to favorites: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Message: "Added to favorites successfully", + }) +} + +func (h *FavoritesHandler) RemoveFromFavorites(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserIDFromContext(r.Context()) + if !ok { + http.Error(w, "User ID not found in context", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + mediaID := vars["id"] + mediaType := r.URL.Query().Get("type") + + if mediaID == "" { + http.Error(w, "Media ID is required", http.StatusBadRequest) + return + } + + if mediaType == "" { + mediaType = "movie" // По умолчанию фильм для обратной совместимости + } + + if mediaType != "movie" && mediaType != "tv" { + http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest) + return + } + + err := h.favoritesService.RemoveFromFavorites(userID, mediaID, mediaType) + if err != nil { + http.Error(w, "Failed to remove from favorites: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Message: "Removed from favorites successfully", + }) +} + +func (h *FavoritesHandler) CheckIsFavorite(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserIDFromContext(r.Context()) + if !ok { + http.Error(w, "User ID not found in context", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + mediaID := vars["id"] + mediaType := r.URL.Query().Get("type") + + if mediaID == "" { + http.Error(w, "Media ID is required", http.StatusBadRequest) + return + } + + if mediaType == "" { + mediaType = "movie" // По умолчанию фильм для обратной совместимости + } + + if mediaType != "movie" && mediaType != "tv" { + http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest) + return + } + + isFavorite, err := h.favoritesService.IsFavorite(userID, mediaID, mediaType) + if err != nil { + http.Error(w, "Failed to check favorite status: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Data: map[string]bool{"isFavorite": isFavorite}, + }) +} + +// fetchMediaInfoRussian получает информацию о медиа на русском языке из TMDB +func (h *FavoritesHandler) fetchMediaInfoRussian(mediaID, mediaType string) (*models.MediaInfo, error) { + var url string + if mediaType == "movie" { + url = fmt.Sprintf("https://api.themoviedb.org/3/movie/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken) + } else { + url = fmt.Sprintf("https://api.themoviedb.org/3/tv/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken) + } + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch from TMDB: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("TMDB API error: status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var tmdbResponse map[string]interface{} + if err := json.Unmarshal(body, &tmdbResponse); err != nil { + return nil, fmt.Errorf("failed to parse TMDB response: %w", err) + } + + mediaInfo := &models.MediaInfo{ + ID: mediaID, + MediaType: mediaType, + } + + // Заполняем информацию в зависимости от типа медиа + if mediaType == "movie" { + if title, ok := tmdbResponse["title"].(string); ok { + mediaInfo.Title = title + } + if originalTitle, ok := tmdbResponse["original_title"].(string); ok { + mediaInfo.OriginalTitle = originalTitle + } + if releaseDate, ok := tmdbResponse["release_date"].(string); ok { + mediaInfo.ReleaseDate = releaseDate + } + } else { + if name, ok := tmdbResponse["name"].(string); ok { + mediaInfo.Title = name + } + if originalName, ok := tmdbResponse["original_name"].(string); ok { + mediaInfo.OriginalTitle = originalName + } + if firstAirDate, ok := tmdbResponse["first_air_date"].(string); ok { + mediaInfo.FirstAirDate = firstAirDate + } + } + + // Общие поля + if overview, ok := tmdbResponse["overview"].(string); ok { + mediaInfo.Overview = overview + } + if posterPath, ok := tmdbResponse["poster_path"].(string); ok { + mediaInfo.PosterPath = posterPath + } + if backdropPath, ok := tmdbResponse["backdrop_path"].(string); ok { + mediaInfo.BackdropPath = backdropPath + } + if voteAverage, ok := tmdbResponse["vote_average"].(float64); ok { + mediaInfo.VoteAverage = voteAverage + } + if voteCount, ok := tmdbResponse["vote_count"].(float64); ok { + mediaInfo.VoteCount = int(voteCount) + } + if popularity, ok := tmdbResponse["popularity"].(float64); ok { + mediaInfo.Popularity = popularity + } + + // Жанры + if genres, ok := tmdbResponse["genres"].([]interface{}); ok { + for _, genre := range genres { + if genreMap, ok := genre.(map[string]interface{}); ok { + if genreID, ok := genreMap["id"].(float64); ok { + mediaInfo.GenreIDs = append(mediaInfo.GenreIDs, int(genreID)) + } + } + } + } + + return mediaInfo, nil +} diff --git a/pkg/handlers/health.go b/pkg/handlers/health.go index 0470a78..dafbec7 100644 --- a/pkg/handlers/health.go +++ b/pkg/handlers/health.go @@ -26,4 +26,4 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) { }) } -var startTime = time.Now() \ No newline at end of file +var startTime = time.Now() diff --git a/pkg/handlers/images.go b/pkg/handlers/images.go index 45cb347..12d744f 100644 --- a/pkg/handlers/images.go +++ b/pkg/handlers/images.go @@ -9,15 +9,12 @@ import ( "strings" "github.com/gorilla/mux" + "neomovies-api/pkg/config" ) type ImagesHandler struct{} -func NewImagesHandler() *ImagesHandler { - return &ImagesHandler{} -} - -const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p" +func NewImagesHandler() *ImagesHandler { return &ImagesHandler{} } func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) @@ -29,22 +26,18 @@ func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) { return } - // Если запрашивается placeholder, возвращаем локальный файл if imagePath == "placeholder.jpg" { h.servePlaceholder(w, r) return } - // Проверяем размер изображения validSizes := []string{"w92", "w154", "w185", "w342", "w500", "w780", "original"} if !h.isValidSize(size, validSizes) { size = "original" } - // Формируем URL изображения - imageURL := fmt.Sprintf("%s/%s/%s", TMDB_IMAGE_BASE_URL, size, imagePath) + imageURL := fmt.Sprintf("%s/%s/%s", config.TMDBImageBaseURL, size, imagePath) - // Получаем изображение resp, err := http.Get(imageURL) if err != nil { h.servePlaceholder(w, r) @@ -57,23 +50,19 @@ func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) { return } - // Устанавливаем заголовки if contentType := resp.Header.Get("Content-Type"); contentType != "" { w.Header().Set("Content-Type", contentType) } - w.Header().Set("Cache-Control", "public, max-age=31536000") // кэшируем на 1 год + w.Header().Set("Cache-Control", "public, max-age=31536000") - // Передаем изображение клиенту _, err = io.Copy(w, resp.Body) if err != nil { - // Если ошибка при копировании, отдаем placeholder h.servePlaceholder(w, r) return } } func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) { - // Попробуем найти placeholder изображение placeholderPaths := []string{ "./assets/placeholder.jpg", "./public/images/placeholder.jpg", @@ -89,7 +78,6 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) } if placeholderPath == "" { - // Если placeholder не найден, создаем простую SVG заглушку h.serveSVGPlaceholder(w, r) return } @@ -101,7 +89,6 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) } defer file.Close() - // Определяем content-type по расширению ext := strings.ToLower(filepath.Ext(placeholderPath)) switch ext { case ".jpg", ".jpeg": @@ -116,7 +103,7 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "image/jpeg") } - w.Header().Set("Cache-Control", "public, max-age=3600") // кэшируем placeholder на 1 час + w.Header().Set("Cache-Control", "public, max-age=3600") _, err = io.Copy(w, file) if err != nil { @@ -144,4 +131,4 @@ func (h *ImagesHandler) isValidSize(size string, validSizes []string) bool { } } return false -} \ No newline at end of file +} diff --git a/pkg/handlers/movie.go b/pkg/handlers/movie.go index 70a0bc4..26f59d9 100644 --- a/pkg/handlers/movie.go +++ b/pkg/handlers/movie.go @@ -7,7 +7,6 @@ import ( "github.com/gorilla/mux" - "neomovies-api/pkg/middleware" "neomovies-api/pkg/models" "neomovies-api/pkg/services" ) @@ -190,74 +189,6 @@ func (h *MovieHandler) GetSimilar(w http.ResponseWriter, r *http.Request) { }) } -func (h *MovieHandler) GetFavorites(w http.ResponseWriter, r *http.Request) { - userID, ok := middleware.GetUserIDFromContext(r.Context()) - if !ok { - http.Error(w, "User ID not found in context", http.StatusInternalServerError) - return - } - - language := r.URL.Query().Get("language") - - movies, err := h.movieService.GetFavorites(userID, language) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: movies, - }) -} - -func (h *MovieHandler) AddToFavorites(w http.ResponseWriter, r *http.Request) { - userID, ok := middleware.GetUserIDFromContext(r.Context()) - if !ok { - http.Error(w, "User ID not found in context", http.StatusInternalServerError) - return - } - - vars := mux.Vars(r) - movieID := vars["id"] - - err := h.movieService.AddToFavorites(userID, movieID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Message: "Movie added to favorites", - }) -} - -func (h *MovieHandler) RemoveFromFavorites(w http.ResponseWriter, r *http.Request) { - userID, ok := middleware.GetUserIDFromContext(r.Context()) - if !ok { - http.Error(w, "User ID not found in context", http.StatusInternalServerError) - return - } - - vars := mux.Vars(r) - movieID := vars["id"] - - err := h.movieService.RemoveFromFavorites(userID, movieID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Message: "Movie removed from favorites", - }) -} - func (h *MovieHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id, err := strconv.Atoi(vars["id"]) @@ -284,11 +215,11 @@ func getIntQuery(r *http.Request, key string, defaultValue int) int { if str == "" { return defaultValue } - + value, err := strconv.Atoi(str) if err != nil { return defaultValue } - + return value -} \ No newline at end of file +} diff --git a/pkg/handlers/players.go b/pkg/handlers/players.go index 5185485..f8a18d2 100644 --- a/pkg/handlers/players.go +++ b/pkg/handlers/players.go @@ -9,6 +9,7 @@ import ( "net/url" "strconv" "strings" + "time" "github.com/gorilla/mux" "neomovies-api/pkg/config" diff --git a/pkg/handlers/reactions.go b/pkg/handlers/reactions.go index 27c94fd..abba5ed 100644 --- a/pkg/handlers/reactions.go +++ b/pkg/handlers/reactions.go @@ -16,12 +16,9 @@ type ReactionsHandler struct { } func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler { - return &ReactionsHandler{ - reactionsService: reactionsService, - } + return &ReactionsHandler{reactionsService: reactionsService} } -// Получить счетчики реакций для медиа (публичный эндпоинт) func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) mediaType := vars["mediaType"] @@ -42,7 +39,6 @@ func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Requ json.NewEncoder(w).Encode(counts) } -// Получить реакцию текущего пользователя (требует авторизации) func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserIDFromContext(r.Context()) if !ok { @@ -59,21 +55,20 @@ func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) return } - reaction, err := h.reactionsService.GetUserReaction(userID, mediaType, mediaID) + reactionType, err := h.reactionsService.GetMyReaction(userID, mediaType, mediaID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") - if reaction == nil { + if reactionType == "" { json.NewEncoder(w).Encode(map[string]interface{}{}) } else { - json.NewEncoder(w).Encode(reaction) + json.NewEncoder(w).Encode(map[string]string{"type": reactionType}) } } -// Установить реакцию пользователя (требует авторизации) func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserIDFromContext(r.Context()) if !ok { @@ -93,31 +88,24 @@ func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) { var request struct { Type string `json:"type"` } - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } - if request.Type == "" { http.Error(w, "Reaction type is required", http.StatusBadRequest) return } - err := h.reactionsService.SetUserReaction(userID, mediaType, mediaID, request.Type) - if err != nil { + if err := h.reactionsService.SetReaction(userID, mediaType, mediaID, request.Type); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Message: "Reaction set successfully", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction set successfully"}) } -// Удалить реакцию пользователя (требует авторизации) func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserIDFromContext(r.Context()) if !ok { @@ -134,20 +122,15 @@ func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request return } - err := h.reactionsService.RemoveUserReaction(userID, mediaType, mediaID) - if err != nil { + if err := h.reactionsService.RemoveReaction(userID, mediaType, mediaID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Message: "Reaction removed successfully", - }) + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction removed successfully"}) } -// Получить все реакции пользователя (требует авторизации) func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.GetUserIDFromContext(r.Context()) if !ok { @@ -164,8 +147,5 @@ func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: reactions, - }) -} \ No newline at end of file + json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: reactions}) +} diff --git a/pkg/handlers/search.go b/pkg/handlers/search.go index 231140a..9f2f700 100644 --- a/pkg/handlers/search.go +++ b/pkg/handlers/search.go @@ -42,4 +42,4 @@ func (h *SearchHandler) MultiSearch(w http.ResponseWriter, r *http.Request) { Success: true, Data: results, }) -} \ No newline at end of file +} diff --git a/pkg/handlers/torrents.go b/pkg/handlers/torrents.go index b491996..f8a4992 100644 --- a/pkg/handlers/torrents.go +++ b/pkg/handlers/torrents.go @@ -123,12 +123,12 @@ func (h *TorrentsHandler) SearchTorrents(w http.ResponseWriter, r *http.Request) // Группируем сначала по сезонам, затем по качеству внутри каждого сезона seasonGroups := h.torrentService.GroupBySeason(results.Results) finalGroups := make(map[string]map[string][]models.TorrentResult) - + for season, torrents := range seasonGroups { qualityGroups := h.torrentService.GroupByQuality(torrents) finalGroups[season] = qualityGroups } - + response["grouped"] = true response["groups"] = finalGroups } else if options.GroupByQuality { @@ -364,4 +364,4 @@ func (h *TorrentsHandler) SearchByQuery(w http.ResponseWriter, r *http.Request) Success: true, Data: response, }) -} \ No newline at end of file +} diff --git a/pkg/handlers/tv.go b/pkg/handlers/tv.go index 0a4fb34..95a1965 100644 --- a/pkg/handlers/tv.go +++ b/pkg/handlers/tv.go @@ -203,4 +203,4 @@ func (h *TVHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) { Success: true, Data: externalIDs, }) -} \ No newline at end of file +} diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index f62fee5..80dd770 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -60,4 +60,4 @@ func JWTAuth(secret string) func(http.Handler) http.Handler { func GetUserIDFromContext(ctx context.Context) (string, bool) { userID, ok := ctx.Value(UserIDKey).(string) return userID, ok -} \ No newline at end of file +} diff --git a/pkg/models/favorite.go b/pkg/models/favorite.go new file mode 100644 index 0000000..ef48d8f --- /dev/null +++ b/pkg/models/favorite.go @@ -0,0 +1,24 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" +) + +type Favorite struct { + ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` + UserID string `json:"userId" bson:"userId"` + MediaID string `json:"mediaId" bson:"mediaId"` + MediaType string `json:"mediaType" bson:"mediaType"` // "movie" or "tv" + Title string `json:"title" bson:"title"` + PosterPath string `json:"posterPath" bson:"posterPath"` + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` +} + +type FavoriteRequest struct { + MediaID string `json:"mediaId" validate:"required"` + MediaType string `json:"mediaType" validate:"required,oneof=movie tv"` + Title string `json:"title" validate:"required"` + PosterPath string `json:"posterPath"` +} diff --git a/pkg/models/movie.go b/pkg/models/movie.go index 0669d4d..0253f97 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -1,28 +1,45 @@ package models +// MediaInfo represents media information structure used by handlers and services +type MediaInfo struct { + ID string `json:"id"` + Title string `json:"title"` + OriginalTitle string `json:"original_title,omitempty"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date,omitempty"` + FirstAirDate string `json:"first_air_date,omitempty"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` + MediaType string `json:"media_type"` + Popularity float64 `json:"popularity"` + GenreIDs []int `json:"genre_ids"` +} + type Movie struct { - ID int `json:"id"` - Title string `json:"title"` - OriginalTitle string `json:"original_title"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - ReleaseDate string `json:"release_date"` - GenreIDs []int `json:"genre_ids"` - Genres []Genre `json:"genres"` - VoteAverage float64 `json:"vote_average"` - VoteCount int `json:"vote_count"` - Popularity float64 `json:"popularity"` - Adult bool `json:"adult"` - Video bool `json:"video"` - OriginalLanguage string `json:"original_language"` - Runtime int `json:"runtime,omitempty"` - Budget int64 `json:"budget,omitempty"` - Revenue int64 `json:"revenue,omitempty"` - Status string `json:"status,omitempty"` - Tagline string `json:"tagline,omitempty"` - Homepage string `json:"homepage,omitempty"` - IMDbID string `json:"imdb_id,omitempty"` + ID int `json:"id"` + Title string `json:"title"` + OriginalTitle string `json:"original_title"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date"` + GenreIDs []int `json:"genre_ids"` + Genres []Genre `json:"genres"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` + Popularity float64 `json:"popularity"` + Adult bool `json:"adult"` + Video bool `json:"video"` + OriginalLanguage string `json:"original_language"` + Runtime int `json:"runtime,omitempty"` + Budget int64 `json:"budget,omitempty"` + Revenue int64 `json:"revenue,omitempty"` + Status string `json:"status,omitempty"` + Tagline string `json:"tagline,omitempty"` + Homepage string `json:"homepage,omitempty"` + IMDbID string `json:"imdb_id,omitempty"` BelongsToCollection *Collection `json:"belongs_to_collection,omitempty"` ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"` ProductionCountries []ProductionCountry `json:"production_countries,omitempty"` @@ -30,29 +47,29 @@ type Movie struct { } type TVShow struct { - ID int `json:"id"` - Name string `json:"name"` - OriginalName string `json:"original_name"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - FirstAirDate string `json:"first_air_date"` - LastAirDate string `json:"last_air_date"` - GenreIDs []int `json:"genre_ids"` - Genres []Genre `json:"genres"` - VoteAverage float64 `json:"vote_average"` - VoteCount int `json:"vote_count"` - Popularity float64 `json:"popularity"` - OriginalLanguage string `json:"original_language"` - OriginCountry []string `json:"origin_country"` - NumberOfEpisodes int `json:"number_of_episodes,omitempty"` - NumberOfSeasons int `json:"number_of_seasons,omitempty"` - Status string `json:"status,omitempty"` - Type string `json:"type,omitempty"` - Homepage string `json:"homepage,omitempty"` - InProduction bool `json:"in_production,omitempty"` - Languages []string `json:"languages,omitempty"` - Networks []Network `json:"networks,omitempty"` + ID int `json:"id"` + Name string `json:"name"` + OriginalName string `json:"original_name"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + FirstAirDate string `json:"first_air_date"` + LastAirDate string `json:"last_air_date"` + GenreIDs []int `json:"genre_ids"` + Genres []Genre `json:"genres"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` + Popularity float64 `json:"popularity"` + OriginalLanguage string `json:"original_language"` + OriginCountry []string `json:"origin_country"` + NumberOfEpisodes int `json:"number_of_episodes,omitempty"` + NumberOfSeasons int `json:"number_of_seasons,omitempty"` + Status string `json:"status,omitempty"` + Type string `json:"type,omitempty"` + Homepage string `json:"homepage,omitempty"` + InProduction bool `json:"in_production,omitempty"` + Languages []string `json:"languages,omitempty"` + Networks []Network `json:"networks,omitempty"` ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"` ProductionCountries []ProductionCountry `json:"production_countries,omitempty"` SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"` @@ -63,23 +80,23 @@ type TVShow struct { // MultiSearchResult для мультипоиска type MultiSearchResult struct { - ID int `json:"id"` - MediaType string `json:"media_type"` // "movie" или "tv" - Title string `json:"title,omitempty"` // для фильмов - Name string `json:"name,omitempty"` // для сериалов - OriginalTitle string `json:"original_title,omitempty"` - OriginalName string `json:"original_name,omitempty"` - Overview string `json:"overview"` - PosterPath string `json:"poster_path"` - BackdropPath string `json:"backdrop_path"` - ReleaseDate string `json:"release_date,omitempty"` // для фильмов - FirstAirDate string `json:"first_air_date,omitempty"` // для сериалов - GenreIDs []int `json:"genre_ids"` - VoteAverage float64 `json:"vote_average"` - VoteCount int `json:"vote_count"` - Popularity float64 `json:"popularity"` - Adult bool `json:"adult"` - OriginalLanguage string `json:"original_language"` + ID int `json:"id"` + MediaType string `json:"media_type"` // "movie" или "tv" + Title string `json:"title,omitempty"` // для фильмов + Name string `json:"name,omitempty"` // для сериалов + OriginalTitle string `json:"original_title,omitempty"` + OriginalName string `json:"original_name,omitempty"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + BackdropPath string `json:"backdrop_path"` + ReleaseDate string `json:"release_date,omitempty"` // для фильмов + FirstAirDate string `json:"first_air_date,omitempty"` // для сериалов + GenreIDs []int `json:"genre_ids"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` + Popularity float64 `json:"popularity"` + Adult bool `json:"adult"` + OriginalLanguage string `json:"original_language"` OriginCountry []string `json:"origin_country,omitempty"` } @@ -159,6 +176,31 @@ type Season struct { SeasonNumber int `json:"season_number"` } +type SeasonDetails struct { + AirDate string `json:"air_date"` + Episodes []Episode `json:"episodes"` + Name string `json:"name"` + Overview string `json:"overview"` + ID int `json:"id"` + PosterPath string `json:"poster_path"` + SeasonNumber int `json:"season_number"` +} + +type Episode struct { + AirDate string `json:"air_date"` + EpisodeNumber int `json:"episode_number"` + ID int `json:"id"` + Name string `json:"name"` + Overview string `json:"overview"` + ProductionCode string `json:"production_code"` + Runtime int `json:"runtime"` + SeasonNumber int `json:"season_number"` + ShowID int `json:"show_id"` + StillPath string `json:"still_path"` + VoteAverage float64 `json:"vote_average"` + VoteCount int `json:"vote_count"` +} + type TMDBResponse struct { Page int `json:"page"` Results []Movie `json:"results"` @@ -174,12 +216,12 @@ type TMDBTVResponse struct { } type SearchParams struct { - Query string `json:"query"` - Page int `json:"page"` - Language string `json:"language"` - Region string `json:"region"` - Year int `json:"year"` - PrimaryReleaseYear int `json:"primary_release_year"` + Query string `json:"query"` + Page int `json:"page"` + Language string `json:"language"` + Region string `json:"region"` + Year int `json:"year"` + PrimaryReleaseYear int `json:"primary_release_year"` } type APIResponse struct { @@ -191,23 +233,23 @@ type APIResponse struct { // Модели для торрентов type TorrentResult struct { - Title string `json:"title"` - Tracker string `json:"tracker"` - Size string `json:"size"` - Seeders int `json:"seeders"` - Peers int `json:"peers"` - Leechers int `json:"leechers"` - Quality string `json:"quality"` - Voice []string `json:"voice,omitempty"` - Types []string `json:"types,omitempty"` - Seasons []int `json:"seasons,omitempty"` - Category string `json:"category"` - MagnetLink string `json:"magnet"` - TorrentLink string `json:"torrent_link,omitempty"` - Details string `json:"details,omitempty"` - PublishDate string `json:"publish_date"` - AddedDate string `json:"added_date,omitempty"` - Source string `json:"source"` + Title string `json:"title"` + Tracker string `json:"tracker"` + Size string `json:"size"` + Seeders int `json:"seeders"` + Peers int `json:"peers"` + Leechers int `json:"leechers"` + Quality string `json:"quality"` + Voice []string `json:"voice,omitempty"` + Types []string `json:"types,omitempty"` + Seasons []int `json:"seasons,omitempty"` + Category string `json:"category"` + MagnetLink string `json:"magnet"` + TorrentLink string `json:"torrent_link,omitempty"` + Details string `json:"details,omitempty"` + PublishDate string `json:"publish_date"` + AddedDate string `json:"added_date,omitempty"` + Source string `json:"source"` } type TorrentSearchResponse struct { @@ -222,16 +264,16 @@ type RedAPIResponse struct { } type RedAPITorrent struct { - Title string `json:"Title"` - Tracker string `json:"Tracker"` - Size interface{} `json:"Size"` // Может быть string или number - Seeders int `json:"Seeders"` - Peers int `json:"Peers"` - MagnetUri string `json:"MagnetUri"` - PublishDate string `json:"PublishDate"` - CategoryDesc string `json:"CategoryDesc"` - Details string `json:"Details"` - Info *RedAPITorrentInfo `json:"Info,omitempty"` + Title string `json:"Title"` + Tracker string `json:"Tracker"` + Size interface{} `json:"Size"` // Может быть string или number + Seeders int `json:"Seeders"` + Peers int `json:"Peers"` + MagnetUri string `json:"MagnetUri"` + PublishDate string `json:"PublishDate"` + CategoryDesc string `json:"CategoryDesc"` + Details string `json:"Details"` + Info *RedAPITorrentInfo `json:"Info,omitempty"` } type RedAPITorrentInfo struct { @@ -276,11 +318,11 @@ type PlayerResponse struct { // Модели для реакций type Reaction struct { - ID string `json:"id" bson:"_id,omitempty"` - UserID string `json:"userId" bson:"userId"` - MediaID string `json:"mediaId" bson:"mediaId"` - Type string `json:"type" bson:"type"` - Created string `json:"created" bson:"created"` + ID string `json:"id" bson:"_id,omitempty"` + UserID string `json:"userId" bson:"userId"` + MediaID string `json:"mediaId" bson:"mediaId"` + Type string `json:"type" bson:"type"` + Created string `json:"created" bson:"created"` } type ReactionCounts struct { @@ -289,4 +331,4 @@ type ReactionCounts struct { Think int `json:"think"` Bore int `json:"bore"` Shit int `json:"shit"` -} \ No newline at end of file +} diff --git a/pkg/models/user.go b/pkg/models/user.go index 0408b5b..6f42f0f 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -7,19 +7,22 @@ import ( ) type User struct { - ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` - Email string `json:"email" bson:"email" validate:"required,email"` - Password string `json:"-" bson:"password" validate:"required,min=6"` - Name string `json:"name" bson:"name" validate:"required"` - Avatar string `json:"avatar" bson:"avatar"` - Favorites []string `json:"favorites" bson:"favorites"` - Verified bool `json:"verified" bson:"verified"` - VerificationCode string `json:"-" bson:"verificationCode,omitempty"` - VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"` - IsAdmin bool `json:"isAdmin" bson:"isAdmin"` - AdminVerified bool `json:"adminVerified" bson:"adminVerified"` - CreatedAt time.Time `json:"created_at" bson:"createdAt"` - UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"` + ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` + Email string `json:"email" bson:"email" validate:"required,email"` + Password string `json:"-" bson:"password" validate:"required,min=6"` + Name string `json:"name" bson:"name" validate:"required"` + Avatar string `json:"avatar" bson:"avatar"` + Favorites []string `json:"favorites" bson:"favorites"` + Verified bool `json:"verified" bson:"verified"` + VerificationCode string `json:"-" bson:"verificationCode,omitempty"` + VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"` + IsAdmin bool `json:"isAdmin" bson:"isAdmin"` + AdminVerified bool `json:"adminVerified" bson:"adminVerified"` + CreatedAt time.Time `json:"created_at" bson:"createdAt"` + UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"` + Provider string `json:"provider,omitempty" bson:"provider,omitempty"` + GoogleID string `json:"googleId,omitempty" bson:"googleId,omitempty"` + RefreshTokens []RefreshToken `json:"-" bson:"refreshTokens,omitempty"` } type LoginRequest struct { @@ -34,8 +37,9 @@ type RegisterRequest struct { } type AuthResponse struct { - Token string `json:"token"` - User User `json:"user"` + Token string `json:"token"` + RefreshToken string `json:"refreshToken"` + User User `json:"user"` } type VerifyEmailRequest struct { @@ -45,4 +49,21 @@ type VerifyEmailRequest struct { type ResendCodeRequest struct { Email string `json:"email" validate:"required,email"` -} \ No newline at end of file +} + +type RefreshToken struct { + Token string `json:"token" bson:"token"` + ExpiresAt time.Time `json:"expiresAt" bson:"expiresAt"` + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + UserAgent string `json:"userAgent,omitempty" bson:"userAgent,omitempty"` + IPAddress string `json:"ipAddress,omitempty" bson:"ipAddress,omitempty"` +} + +type TokenPair struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` +} + +type RefreshTokenRequest struct { + RefreshToken string `json:"refreshToken" validate:"required"` +} diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go index 4d91c8d..652eab5 100644 --- a/pkg/monitor/monitor.go +++ b/pkg/monitor/monitor.go @@ -12,16 +12,16 @@ func RequestMonitor() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() - + // Создаем wrapper для ResponseWriter чтобы получить статус код ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} - + // Выполняем запрос next.ServeHTTP(ww, r) - + // Вычисляем время выполнения duration := time.Since(start) - + // Форматируем URL (обрезаем если слишком длинный) url := r.URL.Path if r.URL.RawQuery != "" { @@ -30,11 +30,11 @@ func RequestMonitor() func(http.Handler) http.Handler { if len(url) > 60 { url = url[:57] + "..." } - + // Определяем цвет статуса statusColor := getStatusColor(ww.statusCode) methodColor := getMethodColor(r.Method) - + // Выводим информацию о запросе fmt.Printf("\033[2K\r%s%-6s\033[0m %s%-3d\033[0m │ %-60s │ %6.2fms\n", methodColor, r.Method, @@ -88,4 +88,4 @@ func getMethodColor(method string) string { default: return "\033[37m" // Белый } -} \ No newline at end of file +} diff --git a/pkg/services/auth.go b/pkg/services/auth.go index 71df6b6..8b2f1c2 100644 --- a/pkg/services/auth.go +++ b/pkg/services/auth.go @@ -4,159 +4,240 @@ import ( "context" "errors" "fmt" + "io" "math/rand" + "net/http" + "net/url" + "sync" "time" + "encoding/json" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" "golang.org/x/crypto/bcrypt" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" "neomovies-api/pkg/models" ) +// AuthService contains the database connection, JWT secret, and email service. type AuthService struct { - db *mongo.Database - jwtSecret string - emailService *EmailService + db *mongo.Database + jwtSecret string + emailService *EmailService + baseURL string + googleClientID string + googleClientSecret string + googleRedirectURL string + frontendURL 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, baseURL string, googleClientID string, googleClientSecret string, googleRedirectURL string, frontendURL string) *AuthService { service := &AuthService{ - db: db, - jwtSecret: jwtSecret, - emailService: emailService, + db: db, + jwtSecret: jwtSecret, + emailService: emailService, + baseURL: baseURL, + googleClientID: googleClientID, + googleClientSecret: googleClientSecret, + googleRedirectURL: googleRedirectURL, + frontendURL: frontendURL, } - - // Запускаем тест подключения к базе данных - 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 +func (s *AuthService) googleOAuthConfig() *oauth2.Config { + redirectURL := s.googleRedirectURL + if redirectURL == "" && s.baseURL != "" { + redirectURL = fmt.Sprintf("%s/api/v1/auth/google/callback", s.baseURL) } - - 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 + return &oauth2.Config{ + ClientID: s.googleClientID, + ClientSecret: s.googleClientSecret, + RedirectURL: redirectURL, + Scopes: []string{"openid", "email", "profile"}, + Endpoint: google.Endpoint, } - - 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) GetGoogleLoginURL(state string) (string, error) { + cfg := s.googleOAuthConfig() + if cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" { + return "", errors.New("google oauth not configured") + } + return cfg.AuthCodeURL(state, oauth2.AccessTypeOffline), nil +} + +type googleUserInfo struct { + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + Picture string `json:"picture"` + EmailVerified bool `json:"email_verified"` +} + +// BuildFrontendRedirect builds frontend URL for redirect after OAuth; returns false if not configured +func (s *AuthService) BuildFrontendRedirect(token string, authErr string) (string, bool) { + if s.frontendURL == "" { + return "", false + } + if authErr != "" { + u, _ := url.Parse(s.frontendURL + "/login") + q := u.Query() + q.Set("oauth", "google") + q.Set("error", authErr) + u.RawQuery = q.Encode() + return u.String(), true + } + u, _ := url.Parse(s.frontendURL + "/auth/callback") + q := u.Query() + q.Set("provider", "google") + q.Set("token", token) + u.RawQuery = q.Encode() + return u.String(), true +} + +func (s *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*models.AuthResponse, error) { + cfg := s.googleOAuthConfig() + tok, err := cfg.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("failed to exchange code: %w", err) + } + + client := cfg.Client(ctx, tok) + resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") + if err != nil { + return nil, fmt.Errorf("failed to fetch userinfo: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("userinfo request failed: status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var gUser googleUserInfo + if err := json.Unmarshal(body, &gUser); err != nil { + return nil, fmt.Errorf("failed to parse userinfo: %w", err) + } + if gUser.Email == "" { + return nil, errors.New("email not provided by Google") + } + + collection := s.db.Collection("users") + + // Try by googleId first + var user models.User + err = collection.FindOne(ctx, bson.M{"googleId": gUser.Sub}).Decode(&user) + if err == mongo.ErrNoDocuments { + // Try by email + err = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user) + } + if err == mongo.ErrNoDocuments { + // Create new user + user = models.User{ + ID: primitive.NewObjectID(), + Email: gUser.Email, + Password: "", + Name: gUser.Name, + Avatar: gUser.Picture, + Favorites: []string{}, + Verified: true, + IsAdmin: false, + AdminVerified: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Provider: "google", + GoogleID: gUser.Sub, + } + if _, err := collection.InsertOne(ctx, user); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + // Existing user: ensure fields + update := bson.M{ + "verified": true, + "provider": "google", + "googleId": gUser.Sub, + "updatedAt": time.Now(), + } + if user.Name == "" && gUser.Name != "" { + update["name"] = gUser.Name + } + if user.Avatar == "" && gUser.Picture != "" { + update["avatar"] = gUser.Picture + } + _, _ = collection.UpdateOne(ctx, bson.M{"_id": user.ID}, bson.M{"$set": update}) + } + + // Generate JWT + if user.ID.IsZero() { + // If we created user above, we already have user.ID set; else fetch updated + _ = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user) + } + tokenPair, err := s.generateTokenPair(user.ID.Hex(), "", "") + if err != nil { + return nil, err + } + + return &models.AuthResponse{ + Token: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + User: user, + }, nil +} + +// 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, - Password: string(hashedPassword), - Name: req.Name, - Favorites: []string{}, - Verified: false, - VerificationCode: code, + ID: primitive.NewObjectID(), + Email: req.Email, + Password: string(hashedPassword), + Name: req.Name, + Favorites: []string{}, + Verified: false, + VerificationCode: code, VerificationExpires: codeExpires, - IsAdmin: false, - AdminVerified: false, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + IsAdmin: false, + AdminVerified: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } _, err = collection.InsertOne(context.Background(), user) @@ -164,7 +245,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,44 +255,43 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface }, nil } -func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) { +// Login authenticates a user. +func (s *AuthService) LoginWithTokens(req models.LoginRequest, userAgent, ipAddress string) (*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()) + tokenPair, err := s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress) if err != nil { return nil, err } return &models.AuthResponse{ - Token: token, - User: user, + Token: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + User: user, }, nil } +// Login authenticates a user (legacy method for backward compatibility). +func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) { + return s.LoginWithTokens(req, "", "") +} + +// GetUserByID retrieves a user by their ID. func (s *AuthService) GetUserByID(userID string) (*models.User, error) { collection := s.db.Collection("users") @@ -230,6 +309,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 +332,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 * 1).Unix(), // Сократил время жизни до 1 часа "iat": time.Now().Unix(), "jti": uuid.New().String(), } @@ -264,7 +345,159 @@ func (s *AuthService) generateJWT(userID string) (string, error) { return token.SignedString([]byte(s.jwtSecret)) } -// Верификация email +// generateRefreshToken generates a new refresh token +func (s *AuthService) generateRefreshToken() string { + return uuid.New().String() +} + +// generateTokenPair generates both access and refresh tokens +func (s *AuthService) generateTokenPair(userID, userAgent, ipAddress string) (*models.TokenPair, error) { + accessToken, err := s.generateJWT(userID) + if err != nil { + return nil, err + } + + refreshToken := s.generateRefreshToken() + + // Сохраняем refresh token в базе данных + collection := s.db.Collection("users") + objectID, err := primitive.ObjectIDFromHex(userID) + if err != nil { + return nil, err + } + + refreshTokenDoc := models.RefreshToken{ + Token: refreshToken, + ExpiresAt: time.Now().Add(time.Hour * 24 * 30), // 30 дней + CreatedAt: time.Now(), + UserAgent: userAgent, + IPAddress: ipAddress, + } + + // Удаляем старые истекшие токены и добавляем новый + _, err = collection.UpdateOne( + context.Background(), + bson.M{"_id": objectID}, + bson.M{ + "$pull": bson.M{ + "refreshTokens": bson.M{ + "expiresAt": bson.M{"$lt": time.Now()}, + }, + }, + }, + ) + if err != nil { + return nil, err + } + + _, err = collection.UpdateOne( + context.Background(), + bson.M{"_id": objectID}, + bson.M{ + "$push": bson.M{ + "refreshTokens": refreshTokenDoc, + }, + "$set": bson.M{ + "updatedAt": time.Now(), + }, + }, + ) + if err != nil { + return nil, err + } + + return &models.TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + }, nil +} + +// RefreshAccessToken refreshes an access token using a refresh token +func (s *AuthService) RefreshAccessToken(refreshToken, userAgent, ipAddress string) (*models.TokenPair, error) { + collection := s.db.Collection("users") + + // Найти пользователя с данным refresh токеном + var user models.User + err := collection.FindOne( + context.Background(), + bson.M{ + "refreshTokens": bson.M{ + "$elemMatch": bson.M{ + "token": refreshToken, + "expiresAt": bson.M{"$gt": time.Now()}, + }, + }, + }, + ).Decode(&user) + + if err != nil { + return nil, errors.New("invalid or expired refresh token") + } + + // Удалить использованный refresh token + _, err = collection.UpdateOne( + context.Background(), + bson.M{"_id": user.ID}, + bson.M{ + "$pull": bson.M{ + "refreshTokens": bson.M{ + "token": refreshToken, + }, + }, + }, + ) + if err != nil { + return nil, err + } + + // Создать новую пару токенов + return s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress) +} + +// RevokeRefreshToken revokes a specific refresh token +func (s *AuthService) RevokeRefreshToken(userID, refreshToken string) error { + collection := s.db.Collection("users") + objectID, err := primitive.ObjectIDFromHex(userID) + if err != nil { + return err + } + + _, err = collection.UpdateOne( + context.Background(), + bson.M{"_id": objectID}, + bson.M{ + "$pull": bson.M{ + "refreshTokens": bson.M{ + "token": refreshToken, + }, + }, + }, + ) + return err +} + +// RevokeAllRefreshTokens revokes all refresh tokens for a user +func (s *AuthService) RevokeAllRefreshTokens(userID string) error { + collection := s.db.Collection("users") + objectID, err := primitive.ObjectIDFromHex(userID) + if err != nil { + return err + } + + _, err = collection.UpdateOne( + context.Background(), + bson.M{"_id": objectID}, + bson.M{ + "$set": bson.M{ + "refreshTokens": []models.RefreshToken{}, + "updatedAt": time.Now(), + }, + }, + ) + return err +} + +// 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 +514,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 +539,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 +553,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 +570,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 +578,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.baseURL != "" { // Changed from cubAPIURL to baseURL + 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.baseURL, r.MediaID, r.Type) // Changed from cubAPIURL to baseURL + 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/email.go b/pkg/services/email.go index bbb722c..4a5ad60 100644 --- a/pkg/services/email.go +++ b/pkg/services/email.go @@ -98,7 +98,7 @@ func (s *EmailService) SendVerificationEmail(userEmail, code string) error { func (s *EmailService) SendPasswordResetEmail(userEmail, resetToken string) error { resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, resetToken) - + options := &EmailOptions{ To: []string{userEmail}, Subject: "Сброс пароля Neo Movies", @@ -147,4 +147,4 @@ func (s *EmailService) SendMovieRecommendationEmail(userEmail, userName string, } return s.SendEmail(options) -} \ No newline at end of file +} diff --git a/pkg/services/favorites.go b/pkg/services/favorites.go new file mode 100644 index 0000000..04322cd --- /dev/null +++ b/pkg/services/favorites.go @@ -0,0 +1,184 @@ +package services + +import ( + "context" + "fmt" + "strconv" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + + "neomovies-api/pkg/models" +) + +type FavoritesService struct { + db *mongo.Database + tmdb *TMDBService +} + +func NewFavoritesService(db *mongo.Database, tmdb *TMDBService) *FavoritesService { + return &FavoritesService{ + db: db, + tmdb: tmdb, + } +} + +func (s *FavoritesService) AddToFavorites(userID, mediaID, mediaType string) error { + collection := s.db.Collection("favorites") + + // Проверяем, не добавлен ли уже в избранное + filter := bson.M{ + "userId": userID, + "mediaId": mediaID, + "mediaType": mediaType, + } + + var existingFavorite models.Favorite + err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite) + if err == nil { + // Уже в избранном + return nil + } + + var title, posterPath string + + // Получаем информацию из TMDB в зависимости от типа медиа + mediaIDInt, err := strconv.Atoi(mediaID) + if err != nil { + return fmt.Errorf("invalid media ID: %s", mediaID) + } + + if mediaType == "movie" { + movie, err := s.tmdb.GetMovie(mediaIDInt, "en-US") + if err != nil { + return err + } + title = movie.Title + posterPath = movie.PosterPath + } else if mediaType == "tv" { + tv, err := s.tmdb.GetTVShow(mediaIDInt, "en-US") + if err != nil { + return err + } + title = tv.Name + posterPath = tv.PosterPath + } else { + return fmt.Errorf("invalid media type: %s", mediaType) + } + + // Формируем полный URL для постера + if posterPath != "" { + posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath) + } + + favorite := models.Favorite{ + UserID: userID, + MediaID: mediaID, + MediaType: mediaType, + Title: title, + PosterPath: posterPath, + CreatedAt: time.Now(), + } + + _, err = collection.InsertOne(context.Background(), favorite) + return err +} + +// AddToFavoritesWithInfo adds media to favorites with provided media information +func (s *FavoritesService) AddToFavoritesWithInfo(userID, mediaID, mediaType string, mediaInfo *models.MediaInfo) error { + collection := s.db.Collection("favorites") + + // Проверяем, не добавлен ли уже в избранное + filter := bson.M{ + "userId": userID, + "mediaId": mediaID, + "mediaType": mediaType, + } + + var existingFavorite models.Favorite + err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite) + if err == nil { + // Уже в избранном + return nil + } + + // Формируем полный URL для постера если он есть + posterPath := mediaInfo.PosterPath + if posterPath != "" && posterPath[0] == '/' { + posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath) + } + + favorite := models.Favorite{ + UserID: userID, + MediaID: mediaID, + MediaType: mediaType, + Title: mediaInfo.Title, + PosterPath: posterPath, + CreatedAt: time.Now(), + } + + _, err = collection.InsertOne(context.Background(), favorite) + return err +} + +func (s *FavoritesService) RemoveFromFavorites(userID, mediaID, mediaType string) error { + collection := s.db.Collection("favorites") + + filter := bson.M{ + "userId": userID, + "mediaId": mediaID, + "mediaType": mediaType, + } + + _, err := collection.DeleteOne(context.Background(), filter) + return err +} + +func (s *FavoritesService) GetFavorites(userID string) ([]models.Favorite, error) { + collection := s.db.Collection("favorites") + + filter := bson.M{ + "userId": userID, + } + + cursor, err := collection.Find(context.Background(), filter) + if err != nil { + return nil, err + } + defer cursor.Close(context.Background()) + + var favorites []models.Favorite + err = cursor.All(context.Background(), &favorites) + if err != nil { + return nil, err + } + + // Возвращаем пустой массив вместо nil если нет избранных + if favorites == nil { + favorites = []models.Favorite{} + } + + return favorites, nil +} + +func (s *FavoritesService) IsFavorite(userID, mediaID, mediaType string) (bool, error) { + collection := s.db.Collection("favorites") + + filter := bson.M{ + "userId": userID, + "mediaId": mediaID, + "mediaType": mediaType, + } + + var favorite models.Favorite + err := collection.FindOne(context.Background(), filter).Decode(&favorite) + if err != nil { + if err == mongo.ErrNoDocuments { + return false, nil + } + return false, err + } + + return true, nil +} diff --git a/pkg/services/movie.go b/pkg/services/movie.go index e41624b..6450067 100644 --- a/pkg/services/movie.go +++ b/pkg/services/movie.go @@ -1,23 +1,17 @@ package services import ( - "context" - "strconv" - - "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "neomovies-api/pkg/models" ) type MovieService struct { - db *mongo.Database tmdb *TMDBService } func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService { return &MovieService{ - db: db, tmdb: tmdb, } } @@ -54,57 +48,6 @@ func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBRe return s.tmdb.GetSimilarMovies(id, page, language) } -func (s *MovieService) AddToFavorites(userID string, movieID string) error { - collection := s.db.Collection("users") - - filter := bson.M{"_id": userID} - update := bson.M{ - "$addToSet": bson.M{"favorites": movieID}, - } - - _, err := collection.UpdateOne(context.Background(), filter, update) - return err -} - -func (s *MovieService) RemoveFromFavorites(userID string, movieID string) error { - collection := s.db.Collection("users") - - filter := bson.M{"_id": userID} - update := bson.M{ - "$pull": bson.M{"favorites": movieID}, - } - - _, err := collection.UpdateOne(context.Background(), filter, update) - return err -} - -func (s *MovieService) GetFavorites(userID string, language string) ([]models.Movie, error) { - collection := s.db.Collection("users") - - var user models.User - err := collection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&user) - if err != nil { - return nil, err - } - - var movies []models.Movie - for _, movieIDStr := range user.Favorites { - movieID, err := strconv.Atoi(movieIDStr) - if err != nil { - continue - } - - movie, err := s.tmdb.GetMovie(movieID, language) - if err != nil { - continue - } - - movies = append(movies, *movie) - } - - return movies, nil -} - func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) { return s.tmdb.GetMovieExternalIDs(id) -} \ No newline at end of file +} diff --git a/pkg/services/reactions.go b/pkg/services/reactions.go index 8968407..5082fdb 100644 --- a/pkg/services/reactions.go +++ b/pkg/services/reactions.go @@ -12,6 +12,7 @@ import ( "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" + "neomovies-api/pkg/config" "neomovies-api/pkg/models" ) @@ -27,17 +28,15 @@ func NewReactionsService(db *mongo.Database) *ReactionsService { } } -const CUB_API_URL = "https://cub.rip/api" - -var VALID_REACTIONS = []string{"fire", "nice", "think", "bore", "shit"} +var validReactions = []string{"fire", "nice", "think", "bore", "shit"} // Получить счетчики реакций для медиа из внешнего API (cub.rip) func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) { cubID := fmt.Sprintf("%s_%s", mediaType, mediaID) - - resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", CUB_API_URL, cubID)) + + resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", config.CubAPIBaseURL, cubID)) if err != nil { - return &models.ReactionCounts{}, nil // Возвращаем пустые счетчики при ошибке + return &models.ReactionCounts{}, nil } defer resp.Body.Close() @@ -61,7 +60,6 @@ func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models return &models.ReactionCounts{}, nil } - // Преобразуем в нашу структуру counts := &models.ReactionCounts{} for _, reaction := range response.Result { switch reaction.Type { @@ -81,76 +79,60 @@ func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models return counts, nil } -// Получить реакцию пользователя для медиа -func (s *ReactionsService) GetUserReaction(userID, mediaType, mediaID string) (*models.Reaction, error) { +func (s *ReactionsService) GetMyReaction(userID, mediaType, mediaID string) (string, error) { collection := s.db.Collection("reactions") - fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) + ctx := context.Background() - var reaction models.Reaction - err := collection.FindOne(context.Background(), bson.M{ - "userId": userID, - "mediaId": fullMediaID, - }).Decode(&reaction) - - if err == mongo.ErrNoDocuments { - return nil, nil // Реакции нет + var result struct { + Type string `bson:"type"` } - - return &reaction, err -} - -// Установить реакцию пользователя -func (s *ReactionsService) SetUserReaction(userID, mediaType, mediaID, reactionType string) error { - // Проверяем валидность типа реакции - if !s.isValidReactionType(reactionType) { - return fmt.Errorf("invalid reaction type: %s", reactionType) - } - - collection := s.db.Collection("reactions") - fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) - - // Создаем или обновляем реакцию - filter := bson.M{ - "userId": userID, - "mediaId": fullMediaID, - } - - reaction := models.Reaction{ - UserID: userID, - MediaID: fullMediaID, - Type: reactionType, - Created: time.Now().Format(time.RFC3339), - } - - update := bson.M{ - "$set": reaction, - } - - upsert := true - _, err := collection.UpdateOne(context.Background(), filter, update, &options.UpdateOptions{ - Upsert: &upsert, - }) - + err := collection.FindOne(ctx, bson.M{ + "userId": userID, + "mediaType": mediaType, + "mediaId": mediaID, + }).Decode(&result) if err != nil { - return err + if err == mongo.ErrNoDocuments { + return "", nil + } + return "", err } - - // Отправляем реакцию в cub.rip API - go s.sendReactionToCub(fullMediaID, reactionType) - - return nil + return result.Type, nil } -// Удалить реакцию пользователя -func (s *ReactionsService) RemoveUserReaction(userID, mediaType, mediaID string) error { - collection := s.db.Collection("reactions") - fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) +func (s *ReactionsService) SetReaction(userID, mediaType, mediaID, reactionType string) error { + if !s.isValidReactionType(reactionType) { + return fmt.Errorf("invalid reaction type") + } - _, err := collection.DeleteOne(context.Background(), bson.M{ - "userId": userID, - "mediaId": fullMediaID, + collection := s.db.Collection("reactions") + ctx := context.Background() + + _, err := collection.UpdateOne( + ctx, + bson.M{"userId": userID, "mediaType": mediaType, "mediaId": mediaID}, + bson.M{"$set": bson.M{"type": reactionType, "updatedAt": time.Now()}}, + options.Update().SetUpsert(true), + ) + if err == nil { + go s.sendReactionToCub(fmt.Sprintf("%s_%s", mediaType, mediaID), reactionType) + } + return err +} + +func (s *ReactionsService) RemoveReaction(userID, mediaType, mediaID string) error { + collection := s.db.Collection("reactions") + ctx := context.Background() + + _, err := collection.DeleteOne(ctx, bson.M{ + "userId": userID, + "mediaType": mediaType, + "mediaId": mediaID, }) + fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) + go s.sendReactionToCub(fullMediaID, "remove") + return err } @@ -174,7 +156,7 @@ func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models. } func (s *ReactionsService) isValidReactionType(reactionType string) bool { - for _, valid := range VALID_REACTIONS { + for _, valid := range validReactions { if valid == reactionType { return true } @@ -184,9 +166,8 @@ func (s *ReactionsService) isValidReactionType(reactionType string) bool { // Отправка реакции в cub.rip API (асинхронно) func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) { - // Формируем запрос к cub.rip API - url := fmt.Sprintf("%s/reactions/set", CUB_API_URL) - + url := fmt.Sprintf("%s/reactions/set", config.CubAPIBaseURL) + data := map[string]string{ "mediaId": mediaID, "type": reactionType, @@ -197,16 +178,13 @@ func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) { return } - // В данном случае мы отправляем простой POST запрос - // В будущем можно доработать для отправки JSON данных resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType)) if err != nil { return } defer resp.Body.Close() - // Логируем результат (в продакшене лучше использовать структурированное логирование) if resp.StatusCode == http.StatusOK { fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType) } -} \ No newline at end of file +} diff --git a/pkg/services/tmdb.go b/pkg/services/tmdb.go index c46185c..dc37abc 100644 --- a/pkg/services/tmdb.go +++ b/pkg/services/tmdb.go @@ -52,23 +52,23 @@ func (s *TMDBService) SearchMovies(query string, page int, language, region stri params.Set("query", query) params.Set("page", strconv.Itoa(page)) params.Set("include_adult", "false") - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if region != "" { params.Set("region", region) } - + if year > 0 { params.Set("year", strconv.Itoa(year)) } endpoint := fmt.Sprintf("%s/search/movie?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -119,24 +119,29 @@ func (s *TMDBService) SearchMulti(query string, page int, language string) (*mod return &response, nil } +// Алиас для совместимости с новым WebTorrent handler +func (s *TMDBService) SearchTV(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) { + return s.SearchTVShows(query, page, language, firstAirDateYear) +} + func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("query", query) params.Set("page", strconv.Itoa(page)) params.Set("include_adult", "false") - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if firstAirDateYear > 0 { params.Set("first_air_date_year", strconv.Itoa(firstAirDateYear)) } endpoint := fmt.Sprintf("%s/search/tv?%s", s.baseURL, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -144,7 +149,7 @@ func (s *TMDBService) SearchTVShows(query string, page int, language string, fir func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) { params := url.Values{} - + if language != "" { params.Set("language", language) } else { @@ -152,7 +157,7 @@ func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) { } endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode()) - + var movie models.Movie err := s.makeRequest(endpoint, &movie) return &movie, err @@ -160,7 +165,7 @@ func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) { func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) { params := url.Values{} - + if language != "" { params.Set("language", language) } else { @@ -168,7 +173,7 @@ func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) } endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode()) - + var tvShow models.TVShow err := s.makeRequest(endpoint, &tvShow) return &tvShow, err @@ -176,7 +181,7 @@ func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) { params := url.Values{} - + if language != "" { params.Set("language", language) } else { @@ -184,7 +189,7 @@ func (s *TMDBService) GetGenres(mediaType string, language string) (*models.Genr } endpoint := fmt.Sprintf("%s/genre/%s/list?%s", s.baseURL, mediaType, params.Encode()) - + var response models.GenresResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -205,11 +210,11 @@ func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) { // Объединяем жанры, убирая дубликаты allGenres := make(map[int]models.Genre) - + for _, genre := range movieGenres.Genres { allGenres[genre.ID] = genre } - + for _, genre := range tvGenres.Genres { allGenres[genre.ID] = genre } @@ -226,19 +231,19 @@ func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) { func (s *TMDBService) GetPopularMovies(page int, language, region string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if region != "" { params.Set("region", region) } endpoint := fmt.Sprintf("%s/movie/popular?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -247,19 +252,19 @@ func (s *TMDBService) GetPopularMovies(page int, language, region string) (*mode func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if region != "" { params.Set("region", region) } endpoint := fmt.Sprintf("%s/movie/top_rated?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -268,19 +273,19 @@ func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*mod func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if region != "" { params.Set("region", region) } endpoint := fmt.Sprintf("%s/movie/upcoming?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -289,19 +294,19 @@ func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*mod func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if region != "" { params.Set("region", region) } endpoint := fmt.Sprintf("%s/movie/now_playing?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -310,7 +315,7 @@ func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*m func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -318,7 +323,7 @@ func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*m } endpoint := fmt.Sprintf("%s/movie/%d/recommendations?%s", s.baseURL, id, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -327,7 +332,7 @@ func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*m func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -335,7 +340,7 @@ func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.T } endpoint := fmt.Sprintf("%s/movie/%d/similar?%s", s.baseURL, id, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -344,7 +349,7 @@ func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.T func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -352,7 +357,7 @@ func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDB } endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -361,7 +366,7 @@ func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDB func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -369,7 +374,7 @@ func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMD } endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -378,7 +383,7 @@ func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMD func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -386,7 +391,7 @@ func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMD } endpoint := fmt.Sprintf("%s/tv/on_the_air?%s", s.baseURL, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -395,7 +400,7 @@ func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMD func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -403,7 +408,7 @@ func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models. } endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -412,7 +417,7 @@ func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models. func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -420,7 +425,7 @@ func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*mode } endpoint := fmt.Sprintf("%s/tv/%d/recommendations?%s", s.baseURL, id, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -429,7 +434,7 @@ func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*mode func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -437,7 +442,7 @@ func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models. } endpoint := fmt.Sprintf("%s/tv/%d/similar?%s", s.baseURL, id, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -445,7 +450,7 @@ func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models. func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) { endpoint := fmt.Sprintf("%s/movie/%d/external_ids", s.baseURL, id) - + var ids models.ExternalIDs err := s.makeRequest(endpoint, &ids) return &ids, err @@ -453,7 +458,7 @@ func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) { func (s *TMDBService) GetTVExternalIDs(id int) (*models.ExternalIDs, error) { endpoint := fmt.Sprintf("%s/tv/%d/external_ids", s.baseURL, id) - + var ids models.ExternalIDs err := s.makeRequest(endpoint, &ids) return &ids, err @@ -464,7 +469,7 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) params.Set("page", strconv.Itoa(page)) params.Set("with_genres", strconv.Itoa(genreID)) params.Set("sort_by", "popularity.desc") - + if language != "" { params.Set("language", language) } else { @@ -472,8 +477,39 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) } endpoint := fmt.Sprintf("%s/discover/movie?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err -} \ No newline at end of file +} + +func (s *TMDBService) DiscoverTVByGenre(genreID, page int, language string) (*models.TMDBResponse, error) { + params := url.Values{} + params.Set("page", strconv.Itoa(page)) + params.Set("with_genres", strconv.Itoa(genreID)) + params.Set("sort_by", "popularity.desc") + + if language != "" { + params.Set("language", language) + } else { + params.Set("language", "ru-RU") + } + + endpoint := fmt.Sprintf("%s/discover/tv?%s", s.baseURL, params.Encode()) + + var response models.TMDBResponse + err := s.makeRequest(endpoint, &response) + return &response, err +} + +func (s *TMDBService) GetTVSeason(tvID, seasonNumber int, language string) (*models.SeasonDetails, error) { + if language == "" { + language = "ru-RU" + } + + endpoint := fmt.Sprintf("%s/tv/%d/season/%d?language=%s", s.baseURL, tvID, seasonNumber, language) + + var season models.SeasonDetails + err := s.makeRequest(endpoint, &season) + return &season, err +} diff --git a/pkg/services/torrent.go b/pkg/services/torrent.go index a9d5677..d22eac1 100644 --- a/pkg/services/torrent.go +++ b/pkg/services/torrent.go @@ -21,19 +21,26 @@ type TorrentService struct { apiKey string } +func NewTorrentServiceWithConfig(baseURL, apiKey string) *TorrentService { + return &TorrentService{ + client: &http.Client{Timeout: 8 * time.Second}, + baseURL: baseURL, + apiKey: apiKey, + } +} + func NewTorrentService() *TorrentService { return &TorrentService{ client: &http.Client{Timeout: 8 * time.Second}, baseURL: "http://redapi.cfhttp.top", - apiKey: "", // Может быть установлен через переменные окружения + apiKey: "", } } // SearchTorrents - основной метод поиска торрентов через RedAPI func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) { searchParams := url.Values{} - - // Добавляем все параметры поиска + for key, value := range params { if value != "" { if key == "category" { @@ -43,13 +50,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 +74,7 @@ func (s *TorrentService) SearchTorrents(params map[string]string) (*models.Torre } results := s.parseRedAPIResults(redAPIResponse) - + return &models.TorrentSearchResponse{ Query: params["query"], Results: results, @@ -78,9 +85,8 @@ 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 switch v := torrent.Size.(type) { case string: @@ -92,7 +98,7 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models default: sizeStr = "" } - + result := models.TorrentResult{ Title: torrent.Title, Tracker: torrent.Tracker, @@ -105,10 +111,8 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models Details: torrent.Details, Source: "RedAPI", } - - // Добавляем информацию из Info если она есть + if torrent.Info != nil { - // Обрабатываем качество - может быть строкой или числом switch v := torrent.Info.Quality.(type) { case string: result.Quality = v @@ -117,71 +121,87 @@ 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 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 - - // Устанавливаем тип контента и категорию + params := map[string]string{ + "imdb": imdbID, + "query": title, + "title_original": originalTitle, + "year": year, + } + 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) } - // Выполняем поиск response, err := s.SearchTorrents(params) if err != nil { return nil, err } - // Применяем фильтрацию if options != nil { response.Results = s.FilterByContentType(response.Results, options.ContentType) response.Results = s.FilterTorrents(response.Results, options) response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder) - response.Total = len(response.Results) + } + response.Total = len(response.Results) + + 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 + } } return response, nil @@ -196,15 +216,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 +324,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 +351,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 +397,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 +464,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 +478,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 +489,6 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con } continue } - // Фильтрация по названию, если types недоступно title := strings.ToLower(torrent.Title) switch contentType { @@ -479,7 +496,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 +508,6 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con filtered = append(filtered, torrent) } } - return filtered } @@ -579,7 +595,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 +610,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 +629,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 +638,7 @@ func (s *TorrentService) ExtractQuality(title string) string { return qp.quality } } - + return "Unknown" } @@ -637,14 +653,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 +679,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 +730,7 @@ func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[strin seasons[seasonNumber] = true } } - + // Если сезоны не найдены, добавляем в группу "unknown" if len(seasons) == 0 { groups["Неизвестно"] = append(groups["Неизвестно"], torrent) @@ -734,14 +752,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 +769,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 +793,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 +893,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 +932,4 @@ func (s *TorrentService) containsAny(slice []string, items []string) bool { } } return false -} \ No newline at end of file +} diff --git a/pkg/services/tv.go b/pkg/services/tv.go index 5523a00..18dea61 100644 --- a/pkg/services/tv.go +++ b/pkg/services/tv.go @@ -52,4 +52,4 @@ func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVRes func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) { return s.tmdb.GetTVExternalIDs(id) -} \ No newline at end of file +}