Merge branch 'main' into 'feature/add-streaming-players'

# Conflicts:
#   api/index.go
#   pkg/handlers/players.go
This commit is contained in:
2025-09-28 16:06:26 +00:00
35 changed files with 2618 additions and 1225 deletions

View File

@@ -1,26 +1,28 @@
# MongoDB Configuration # Required
MONGO_URI=mongodb://localhost:27017/neomovies MONGO_URI=
MONGO_DB_NAME=database
TMDB_ACCESS_TOKEN=
JWT_SECRET=
# TMDB API Configuration # Service
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
PORT=3000 PORT=3000
BASE_URL=http://localhost:3000 BASE_URL=http://localhost:3000
NODE_ENV=development NODE_ENV=development
# Production Configuration (для Vercel) # Email (Gmail)
# MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/neomovies GMAIL_USER=
# BASE_URL=https://your-app.vercel.app GMAIL_APP_PASSWORD=
# NODE_ENV=production
# 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

110
README.md
View File

@@ -1,48 +1,16 @@
# Neo Movies API (Go Version) 🎬 # Neo Movies API
> Современный API для поиска фильмов и сериалов, портированный с Node.js на Go REST API для поиска и получения информации о фильмах, использующий TMDB API.
## 🚀 Особенности ## Особенности
- **Высокая производительность** - написан на Go - Поиск фильмов
- 🔒 **JWT аутентификация** с email верификацией - Информация о фильмах
- 🎭 **TMDB API интеграция** для данных о фильмах/сериалах - Популярные фильмы
- 📧 **Email уведомления** через Gmail SMTP - Топ рейтинговые фильмы
- 🔍 **Полнотекстовый поиск** фильмов и сериалов - Предстоящие фильмы
- **Система избранного** для пользователей - Swagger документация
- 🎨 **Современная документация** с Scalar API Reference - Поддержка русского языка
- 🌐 **CORS поддержка** для фронтенд интеграции
- ☁️ **Готов к деплою на Vercel**
## 📚 Основные функции
### 🔐 Аутентификация
- **Регистрация** с email верификацией (6-значный код)
- **Авторизация** JWT токенами
- **Управление профилем** пользователя
- **Email подтверждение** обязательно для входа
### 🎬 TMDB интеграция
- Поиск фильмов и сериалов
- Популярные, топ-рейтинговые, предстоящие
- Детальная информация с трейлерами и актерами
- Рекомендации и похожие фильмы
- Мультипоиск по всем типам контента
### ⭐ Пользовательские функции
- Добавление фильмов в избранное
- Персональные списки
- История просмотров
### 🎭 Плееры
- **Alloha Player** интеграция
- **Lumex Player** интеграция
### 📦 Дополнительно
- **Торренты** - поиск по IMDB ID с фильтрацией
- **Реакции** - лайки/дизлайки с внешним API
- **Изображения** - прокси для TMDB с кэшированием
- **Категории** - жанры и фильмы по категориям
## 🛠 Быстрый старт ## 🛠 Быстрый старт
@@ -50,7 +18,7 @@
1. **Клонирование репозитория** 1. **Клонирование репозитория**
```bash ```bash
git clone <your-repo> git clone https://gitlab.com/foxixus/neomovies-api.git
cd neomovies-api cd neomovies-api
``` ```
@@ -82,22 +50,33 @@ API будет доступен на `http://localhost:3000`
```bash ```bash
# Обязательные # Обязательные
MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/neomovies MONGO_URI=
TMDB_ACCESS_TOKEN=your_tmdb_access_token_here MONGO_DB_NAME=database
JWT_SECRET=your_super_secret_jwt_key_here 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 PORT=3000
BASE_URL=https://api.neomovies.ru BASE_URL=http://localhost:3000
NODE_ENV=production 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 ## 📋 API Endpoints
@@ -113,6 +92,8 @@ POST /api/v1/auth/register # Регистрация (отпр
POST /api/v1/auth/verify # Подтверждение email кодом POST /api/v1/auth/verify # Подтверждение email кодом
POST /api/v1/auth/resend-code # Повторная отправка кода POST /api/v1/auth/resend-code # Повторная отправка кода
POST /api/v1/auth/login # Авторизация 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 # Мультипоиск GET /search/multi # Мультипоиск
@@ -140,14 +121,15 @@ GET /api/v1/tv/{id}/recommendations # Рекомендации
GET /api/v1/tv/{id}/similar # Похожие GET /api/v1/tv/{id}/similar # Похожие
# Плееры # Плееры
GET /api/v1/players/alloha # Alloha плеер GET /api/v1/players/alloha/{imdb_id} # Alloha плеер по IMDb ID
GET /api/v1/players/lumex # Lumex плеер 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/torrents/search/{imdbId} # Поиск торрентов
# Реакции (публичные) # Реакции (публичные)
GET /api/v1/reactions/{type}/{id}/counts # Счетчики реакций GET /api/v1/reactions/{mediaType}/{mediaId}/counts # Счетчики реакций
# Изображения # Изображения
GET /api/v1/images/{size}/{path} # Прокси TMDB изображений GET /api/v1/images/{size}/{path} # Прокси TMDB изображений
@@ -166,10 +148,10 @@ POST /api/v1/favorites/{id} # Добавить в избран
DELETE /api/v1/favorites/{id} # Удалить из избранного DELETE /api/v1/favorites/{id} # Удалить из избранного
# Реакции (приватные) # Реакции (приватные)
GET /api/v1/reactions/{type}/{id}/my-reaction # Моя реакция GET /api/v1/reactions/{mediaType}/{mediaId}/my-reaction # Моя реакция
POST /api/v1/reactions/{type}/{id} # Установить реакцию POST /api/v1/reactions/{mediaType}/{mediaId} # Установить реакцию
DELETE /api/v1/reactions/{type}/{id} # Удалить реакцию DELETE /api/v1/reactions/{mediaType}/{mediaId} # Удалить реакцию
GET /api/v1/reactions/my # Все мои реакции GET /api/v1/reactions/my # Все мои реакции
``` ```
## 📖 Примеры использования ## 📖 Примеры использования

View File

@@ -25,17 +25,14 @@ var (
) )
func initializeApp() { func initializeApp() {
// Загружаем переменные окружения (в Vercel они уже установлены)
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
log.Println("Warning: .env file not found (normal for Vercel)") _ = err
} }
// Инициализируем конфигурацию
globalCfg = config.New() globalCfg = config.New()
// Подключаемся к базе данных
var err error var err error
globalDB, err = database.Connect(globalCfg.MongoURI) globalDB, err = database.Connect(globalCfg.MongoURI, globalCfg.MongoDBName)
if err != nil { if err != nil {
log.Printf("Failed to connect to database: %v", err) log.Printf("Failed to connect to database: %v", err)
initError = err initError = err
@@ -46,29 +43,28 @@ func initializeApp() {
} }
func Handler(w http.ResponseWriter, r *http.Request) { func Handler(w http.ResponseWriter, r *http.Request) {
// Инициализируем приложение один раз
initOnce.Do(initializeApp) initOnce.Do(initializeApp)
// Проверяем, была ли ошибка инициализации
if initError != nil { if initError != nil {
log.Printf("Initialization error: %v", initError) log.Printf("Initialization error: %v", initError)
http.Error(w, "Application initialization failed: "+initError.Error(), http.StatusInternalServerError) http.Error(w, "Application initialization failed: "+initError.Error(), http.StatusInternalServerError)
return return
} }
// Инициализируем сервисы
tmdbService := services.NewTMDBService(globalCfg.TMDBAccessToken) tmdbService := services.NewTMDBService(globalCfg.TMDBAccessToken)
emailService := services.NewEmailService(globalCfg) 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) movieService := services.NewMovieService(globalDB, tmdbService)
tvService := services.NewTVService(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) reactionsService := services.NewReactionsService(globalDB)
// Создаем обработчики
authHandler := handlersPkg.NewAuthHandler(authService) authHandler := handlersPkg.NewAuthHandler(authService)
movieHandler := handlersPkg.NewMovieHandler(movieService) movieHandler := handlersPkg.NewMovieHandler(movieService)
tvHandler := handlersPkg.NewTVHandler(tvService) tvHandler := handlersPkg.NewTVHandler(tvService)
favoritesHandler := handlersPkg.NewFavoritesHandler(favoritesService, globalCfg)
docsHandler := handlersPkg.NewDocsHandler() docsHandler := handlersPkg.NewDocsHandler()
searchHandler := handlersPkg.NewSearchHandler(tmdbService) searchHandler := handlersPkg.NewSearchHandler(tmdbService)
categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService) categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService)
@@ -77,31 +73,27 @@ func Handler(w http.ResponseWriter, r *http.Request) {
reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService) reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService)
imagesHandler := handlersPkg.NewImagesHandler() imagesHandler := handlersPkg.NewImagesHandler()
// Настраиваем маршруты
router := mux.NewRouter() router := mux.NewRouter()
// Документация API на корневом пути
router.HandleFunc("/", docsHandler.ServeDocs).Methods("GET") router.HandleFunc("/", docsHandler.ServeDocs).Methods("GET")
router.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET") router.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET")
// API маршруты
api := router.PathPrefix("/api/v1").Subrouter() api := router.PathPrefix("/api/v1").Subrouter()
// Публичные маршруты
api.HandleFunc("/health", handlersPkg.HealthCheck).Methods("GET") api.HandleFunc("/health", handlersPkg.HealthCheck).Methods("GET")
api.HandleFunc("/auth/register", authHandler.Register).Methods("POST") api.HandleFunc("/auth/register", authHandler.Register).Methods("POST")
api.HandleFunc("/auth/login", authHandler.Login).Methods("POST") api.HandleFunc("/auth/login", authHandler.Login).Methods("POST")
api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST") api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST")
api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).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("/search/multi", searchHandler.MultiSearch).Methods("GET")
router.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
// Категории
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET") api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).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/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
api.HandleFunc("/players/rgshows/{tmdb_id}", playersHandler.GetRgShowsPlayer).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("/players/iframevideo/{kinopoisk_id}/{imdb_id}", playersHandler.GetIframeVideoPlayer).Methods("GET")
api.HandleFunc("/stream/{provider}/{tmdb_id}", playersHandler.GetStreamAPI).Methods("GET") api.HandleFunc("/stream/{provider}/{tmdb_id}", playersHandler.GetStreamAPI).Methods("GET")
// Торренты
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).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") api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET")
// Изображения (прокси для TMDB)
api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET") api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET")
// Маршруты для фильмов
api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET") api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET")
api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET") api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET")
api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET") api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET")
@@ -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}/similar", movieHandler.GetSimilar).Methods("GET")
api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET") api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET")
// Маршруты для сериалов
api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET") api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET")
api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET") api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET")
api.HandleFunc("/tv/top-rated", tvHandler.TopRated).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}/similar", tvHandler.GetSimilar).Methods("GET")
api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET") api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET")
// Приватные маршруты (требуют авторизации)
protected := api.PathPrefix("").Subrouter() protected := api.PathPrefix("").Subrouter()
protected.Use(middleware.JWTAuth(globalCfg.JWTSecret)) protected.Use(middleware.JWTAuth(globalCfg.JWTSecret))
// Избранное protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET")
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET") protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST")
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST") protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE")
protected.HandleFunc("/favorites/{id}", movieHandler.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.GetProfile).Methods("GET")
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") 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}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET")
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST")
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE")
protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET") protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET")
// CORS middleware
corsHandler := handlers.CORS( corsHandler := handlers.CORS(
handlers.AllowedOrigins([]string{"*"}), handlers.AllowedOrigins([]string{"*"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
@@ -167,6 +156,5 @@ func Handler(w http.ResponseWriter, r *http.Request) {
handlers.AllowCredentials(), handlers.AllowCredentials(),
) )
// Обрабатываем запрос
corsHandler(router).ServeHTTP(w, r) corsHandler(router).ServeHTTP(w, r)
} }

4
go.mod
View File

@@ -1,6 +1,6 @@
module neomovies-api module neomovies-api
go 1.22.0 go 1.23.0
toolchain go1.24.2 toolchain go1.24.2
@@ -13,9 +13,11 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
go.mongodb.org/mongo-driver v1.11.6 go.mongodb.org/mongo-driver v1.11.6
golang.org/x/crypto v0.17.0 golang.org/x/crypto v0.17.0
golang.org/x/oauth2 v0.30.0
) )
require ( require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/golang/snappy v0.0.1 // indirect github.com/golang/snappy v0.0.1 // indirect
github.com/klauspost/compress v1.13.6 // indirect github.com/klauspost/compress v1.13.6 // indirect

4
go.sum
View File

@@ -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 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM=
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06/go.mod h1:/wotfjM8I3m8NuIHPz3S8k+CCYH80EqDT8ZeNLqMQm0= 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= 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 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 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/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 h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

97
main.go
View File

@@ -1,7 +1,7 @@
package main package main
import ( import (
"log" "fmt"
"net/http" "net/http"
"os" "os"
@@ -13,38 +13,38 @@ import (
"neomovies-api/pkg/database" "neomovies-api/pkg/database"
appHandlers "neomovies-api/pkg/handlers" appHandlers "neomovies-api/pkg/handlers"
"neomovies-api/pkg/middleware" "neomovies-api/pkg/middleware"
"neomovies-api/pkg/monitor"
"neomovies-api/pkg/services" "neomovies-api/pkg/services"
) )
func main() { func main() {
// Загружаем переменные окружения
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
log.Println("Warning: .env file not found") _ = err
} }
// Инициализируем конфигурацию
cfg := config.New() cfg := config.New()
// Подключаемся к базе данных db, err := database.Connect(cfg.MongoURI, cfg.MongoDBName)
db, err := database.Connect(cfg.MongoURI)
if err != nil { if err != nil {
log.Fatal("Failed to connect to database:", err) fmt.Printf("Failed to connect to database: %v\n", err)
os.Exit(1)
} }
defer database.Disconnect() defer database.Disconnect()
// Инициализируем сервисы
tmdbService := services.NewTMDBService(cfg.TMDBAccessToken) tmdbService := services.NewTMDBService(cfg.TMDBAccessToken)
emailService := services.NewEmailService(cfg) emailService := services.NewEmailService(cfg)
authService := services.NewAuthService(db, cfg.JWTSecret, emailService) authService := services.NewAuthService(db, cfg.JWTSecret, emailService, cfg.BaseURL, cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.GoogleRedirectURL, cfg.FrontendURL)
movieService := services.NewMovieService(db, tmdbService) movieService := services.NewMovieService(db, tmdbService)
tvService := services.NewTVService(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) reactionsService := services.NewReactionsService(db)
// Создаем обработчики
authHandler := appHandlers.NewAuthHandler(authService) authHandler := appHandlers.NewAuthHandler(authService)
movieHandler := appHandlers.NewMovieHandler(movieService) movieHandler := appHandlers.NewMovieHandler(movieService)
tvHandler := appHandlers.NewTVHandler(tvService) tvHandler := appHandlers.NewTVHandler(tvService)
favoritesHandler := appHandlers.NewFavoritesHandler(favoritesService, cfg)
docsHandler := appHandlers.NewDocsHandler() docsHandler := appHandlers.NewDocsHandler()
searchHandler := appHandlers.NewSearchHandler(tmdbService) searchHandler := appHandlers.NewSearchHandler(tmdbService)
categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService) categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService)
@@ -53,35 +53,32 @@ func main() {
reactionsHandler := appHandlers.NewReactionsHandler(reactionsService) reactionsHandler := appHandlers.NewReactionsHandler(reactionsService)
imagesHandler := appHandlers.NewImagesHandler() imagesHandler := appHandlers.NewImagesHandler()
// Настраиваем маршруты
r := mux.NewRouter() r := mux.NewRouter()
// Документация API на корневом пути
r.HandleFunc("/", docsHandler.ServeDocs).Methods("GET") r.HandleFunc("/", docsHandler.ServeDocs).Methods("GET")
r.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET") r.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET")
// API маршруты
api := r.PathPrefix("/api/v1").Subrouter() api := r.PathPrefix("/api/v1").Subrouter()
// Публичные маршруты
api.HandleFunc("/health", appHandlers.HealthCheck).Methods("GET") api.HandleFunc("/health", appHandlers.HealthCheck).Methods("GET")
api.HandleFunc("/auth/register", authHandler.Register).Methods("POST") api.HandleFunc("/auth/register", authHandler.Register).Methods("POST")
api.HandleFunc("/auth/login", authHandler.Login).Methods("POST") api.HandleFunc("/auth/login", authHandler.Login).Methods("POST")
api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST") api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST")
api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).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")
// Поиск api.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
r.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
// Категории
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET") api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET")// Плееры - ИСПРАВЛЕНО: добавлены параметры {imdb_id} api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET")
api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).Methods("GET")
// Плееры api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET") api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") api.HandleFunc("/players/vibix/{imdb_id}", playersHandler.GetVibixPlayer).Methods("GET")
// Торренты
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).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/seasons", torrentsHandler.GetAvailableSeasons).Methods("GET")
api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET") api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET")
// Реакции (публичные)
api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET") api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET")
// Изображения (прокси для TMDB)
api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET") api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET")
// Маршруты для фильмов (некоторые публичные, некоторые приватные)
api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET") api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET")
api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET") api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET")
api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET") api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET")
@@ -104,8 +98,8 @@ func main() {
api.HandleFunc("/movies/{id}", movieHandler.GetByID).Methods("GET") api.HandleFunc("/movies/{id}", movieHandler.GetByID).Methods("GET")
api.HandleFunc("/movies/{id}/recommendations", movieHandler.GetRecommendations).Methods("GET") api.HandleFunc("/movies/{id}/recommendations", movieHandler.GetRecommendations).Methods("GET")
api.HandleFunc("/movies/{id}/similar", movieHandler.GetSimilar).Methods("GET") api.HandleFunc("/movies/{id}/similar", movieHandler.GetSimilar).Methods("GET")
api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET")
// Маршруты для сериалов
api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET") api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET")
api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET") api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET")
api.HandleFunc("/tv/top-rated", tvHandler.TopRated).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}", tvHandler.GetByID).Methods("GET")
api.HandleFunc("/tv/{id}/recommendations", tvHandler.GetRecommendations).Methods("GET") api.HandleFunc("/tv/{id}/recommendations", tvHandler.GetRecommendations).Methods("GET")
api.HandleFunc("/tv/{id}/similar", tvHandler.GetSimilar).Methods("GET") api.HandleFunc("/tv/{id}/similar", tvHandler.GetSimilar).Methods("GET")
api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET")
// Приватные маршруты (требуют авторизации)
protected := api.PathPrefix("").Subrouter() protected := api.PathPrefix("").Subrouter()
protected.Use(middleware.JWTAuth(cfg.JWTSecret)) protected.Use(middleware.JWTAuth(cfg.JWTSecret))
// Избранное protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET")
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET") protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST")
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST") protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE")
protected.HandleFunc("/favorites/{id}", movieHandler.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.GetProfile).Methods("GET")
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") 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}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET")
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST")
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE")
protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET") protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET")
// CORS и другие middleware
corsHandler := handlers.CORS( corsHandler := handlers.CORS(
handlers.AllowedOrigins([]string{"*"}), handlers.AllowedOrigins([]string{"*"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}), handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With", "X-CSRF-Token"}),
handlers.AllowCredentials(), handlers.AllowCredentials(),
handlers.ExposedHeaders([]string{"Authorization", "Content-Type"}),
) )
// Определяем порт var finalHandler http.Handler
port := os.Getenv("PORT") 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 == "" { if port == "" {
port = "3000" port = "3000"
} }
log.Printf("Server starting on port %s", port) if err := http.ListenAndServe(":"+port, finalHandler); err != nil {
log.Printf("API documentation available at: http://localhost:%s/", port) fmt.Printf("❌ Server failed to start: %v\n", err)
os.Exit(1)
log.Fatal(http.ListenAndServe(":"+port, corsHandler(r))) }
} }

View File

@@ -6,50 +6,60 @@ import (
) )
type Config struct { type Config struct {
MongoURI string MongoURI string
TMDBAccessToken string MongoDBName string
JWTSecret string TMDBAccessToken string
Port string JWTSecret string
BaseURL string Port string
NodeEnv string BaseURL string
GmailUser string NodeEnv string
GmailPassword string GmailUser string
LumexURL string GmailPassword string
AllohaToken string LumexURL string
AllohaToken string
RedAPIBaseURL string
RedAPIKey string
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
FrontendURL string
VibixHost string
VibixToken string
} }
func New() *Config { func New() *Config {
// Добавляем отладочное логирование для Vercel
mongoURI := getMongoURI() mongoURI := getMongoURI()
log.Printf("DEBUG: MongoDB URI configured (length: %d)", len(mongoURI))
return &Config{ return &Config{
MongoURI: mongoURI, MongoURI: mongoURI,
TMDBAccessToken: getEnv("TMDB_ACCESS_TOKEN", ""), MongoDBName: getEnv(EnvMongoDBName, DefaultMongoDBName),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"), TMDBAccessToken: getEnv(EnvTMDBAccessToken, ""),
Port: getEnv("PORT", "3000"), JWTSecret: getEnv(EnvJWTSecret, DefaultJWTSecret),
BaseURL: getEnv("BASE_URL", "http://localhost:3000"), Port: getEnv(EnvPort, DefaultPort),
NodeEnv: getEnv("NODE_ENV", "development"), BaseURL: getEnv(EnvBaseURL, DefaultBaseURL),
GmailUser: getEnv("GMAIL_USER", ""), NodeEnv: getEnv(EnvNodeEnv, DefaultNodeEnv),
GmailPassword: getEnv("GMAIL_APP_PASSWORD", ""), GmailUser: getEnv(EnvGmailUser, ""),
LumexURL: getEnv("LUMEX_URL", ""), GmailPassword: getEnv(EnvGmailPassword, ""),
AllohaToken: getEnv("ALLOHA_TOKEN", ""), 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 { func getMongoURI() string {
// Проверяем различные возможные названия переменных for _, envVar := range []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} {
envVars := []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"}
for _, envVar := range envVars {
if value := os.Getenv(envVar); value != "" { if value := os.Getenv(envVar); value != "" {
log.Printf("DEBUG: Using %s for MongoDB connection", envVar) log.Printf("DEBUG: Using %s for MongoDB connection", envVar)
return value return value
} }
} }
// Если ни одна переменная не найдена, возвращаем пустую строку
log.Printf("DEBUG: No MongoDB URI environment variable found") log.Printf("DEBUG: No MongoDB URI environment variable found")
return "" return ""
} }
@@ -59,4 +69,4 @@ func getEnv(key, defaultValue string) string {
return value return value
} }
return defaultValue return defaultValue
} }

36
pkg/config/vars.go Normal file
View File

@@ -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"
)

View File

@@ -10,7 +10,7 @@ import (
var client *mongo.Client 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) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@@ -20,13 +20,11 @@ func Connect(uri string) (*mongo.Database, error) {
return nil, err return nil, err
} }
// Проверяем соединение if err = client.Ping(ctx, nil); err != nil {
err = client.Ping(ctx, nil)
if err != nil {
return nil, err return nil, err
} }
return client.Database("database"), nil return client.Database(dbName), nil
} }
func Disconnect() error { func Disconnect() error {
@@ -40,6 +38,4 @@ func Disconnect() error {
return client.Disconnect(ctx) return client.Disconnect(ctx)
} }
func GetClient() *mongo.Client { func GetClient() *mongo.Client { return client }
return client
}

View File

@@ -3,6 +3,8 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"time"
"go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson"
@@ -16,9 +18,7 @@ type AuthHandler struct {
} }
func NewAuthHandler(authService *services.AuthService) *AuthHandler { func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{ return &AuthHandler{authService: authService}
authService: authService,
}
} }
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(models.APIResponse{ json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "User registered successfully"})
Success: true,
Data: response,
Message: "User registered successfully",
})
} }
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { 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 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 { if err != nil {
// Определяем правильный статус код в зависимости от ошибки
statusCode := http.StatusBadRequest statusCode := http.StatusBadRequest
if err.Error() == "Account not activated. Please verify your email." { if err.Error() == "Account not activated. Please verify your email." {
statusCode = http.StatusForbidden // 403 для неверифицированного email statusCode = http.StatusForbidden
} }
http.Error(w, err.Error(), statusCode) http.Error(w, err.Error(), statusCode)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{ json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "Login successful"})
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) { 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{ json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user})
Success: true,
Data: user,
})
} }
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { 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 return
} }
// Удаляем поля, которые нельзя обновлять через этот эндпоинт
delete(updates, "password") delete(updates, "password")
delete(updates, "email") delete(updates, "email")
delete(updates, "_id") delete(updates, "_id")
@@ -115,14 +175,25 @@ func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{ json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user, Message: "Profile updated successfully"})
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) { func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
var req models.VerifyEmailRequest var req models.VerifyEmailRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 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) json.NewEncoder(w).Encode(response)
} }
// Повторная отправка кода верификации
func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) {
var req models.ResendCodeRequest var req models.ResendCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }
// 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() }

View File

@@ -0,0 +1,7 @@
package handlers
import (
"github.com/google/uuid"
)
func uuidNew() string { return uuid.New().String() }

View File

@@ -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) vars := mux.Vars(r)
categoryID, err := strconv.Atoi(vars["id"]) categoryID, err := strconv.Atoi(vars["id"])
if err != nil { if err != nil {
@@ -67,20 +67,46 @@ func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.R
language = "ru-RU" language = "ru-RU"
} }
// Используем discover API для получения фильмов по жанру mediaType := r.URL.Query().Get("type")
movies, err := h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language) if mediaType == "" {
if err != nil { mediaType = "movie" // По умолчанию фильмы для обратной совместимости
http.Error(w, err.Error(), http.StatusInternalServerError) }
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 return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{ json.NewEncoder(w).Encode(models.APIResponse{
Success: true, 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 { func generateSlug(name string) string {
// Простая функция для создания slug из названия // Простая функция для создания slug из названия
// В реальном проекте стоит использовать более сложную логику // В реальном проекте стоит использовать более сложную логику
@@ -93,4 +119,4 @@ func generateSlug(name string) string {
} }
} }
return result return result
} }

File diff suppressed because it is too large Load Diff

260
pkg/handlers/favorites.go Normal file
View File

@@ -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
}

View File

@@ -26,4 +26,4 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) {
}) })
} }
var startTime = time.Now() var startTime = time.Now()

View File

@@ -9,15 +9,12 @@ import (
"strings" "strings"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"neomovies-api/pkg/config"
) )
type ImagesHandler struct{} type ImagesHandler struct{}
func NewImagesHandler() *ImagesHandler { func NewImagesHandler() *ImagesHandler { return &ImagesHandler{} }
return &ImagesHandler{}
}
const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) { func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
@@ -29,22 +26,18 @@ func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
return return
} }
// Если запрашивается placeholder, возвращаем локальный файл
if imagePath == "placeholder.jpg" { if imagePath == "placeholder.jpg" {
h.servePlaceholder(w, r) h.servePlaceholder(w, r)
return return
} }
// Проверяем размер изображения
validSizes := []string{"w92", "w154", "w185", "w342", "w500", "w780", "original"} validSizes := []string{"w92", "w154", "w185", "w342", "w500", "w780", "original"}
if !h.isValidSize(size, validSizes) { if !h.isValidSize(size, validSizes) {
size = "original" size = "original"
} }
// Формируем URL изображения imageURL := fmt.Sprintf("%s/%s/%s", config.TMDBImageBaseURL, size, imagePath)
imageURL := fmt.Sprintf("%s/%s/%s", TMDB_IMAGE_BASE_URL, size, imagePath)
// Получаем изображение
resp, err := http.Get(imageURL) resp, err := http.Get(imageURL)
if err != nil { if err != nil {
h.servePlaceholder(w, r) h.servePlaceholder(w, r)
@@ -57,23 +50,19 @@ func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
return return
} }
// Устанавливаем заголовки
if contentType := resp.Header.Get("Content-Type"); contentType != "" { if contentType := resp.Header.Get("Content-Type"); contentType != "" {
w.Header().Set("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) _, err = io.Copy(w, resp.Body)
if err != nil { if err != nil {
// Если ошибка при копировании, отдаем placeholder
h.servePlaceholder(w, r) h.servePlaceholder(w, r)
return return
} }
} }
func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) { func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) {
// Попробуем найти placeholder изображение
placeholderPaths := []string{ placeholderPaths := []string{
"./assets/placeholder.jpg", "./assets/placeholder.jpg",
"./public/images/placeholder.jpg", "./public/images/placeholder.jpg",
@@ -89,7 +78,6 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request)
} }
if placeholderPath == "" { if placeholderPath == "" {
// Если placeholder не найден, создаем простую SVG заглушку
h.serveSVGPlaceholder(w, r) h.serveSVGPlaceholder(w, r)
return return
} }
@@ -101,7 +89,6 @@ func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request)
} }
defer file.Close() defer file.Close()
// Определяем content-type по расширению
ext := strings.ToLower(filepath.Ext(placeholderPath)) ext := strings.ToLower(filepath.Ext(placeholderPath))
switch ext { switch ext {
case ".jpg", ".jpeg": 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("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) _, err = io.Copy(w, file)
if err != nil { if err != nil {
@@ -144,4 +131,4 @@ func (h *ImagesHandler) isValidSize(size string, validSizes []string) bool {
} }
} }
return false return false
} }

View File

@@ -7,7 +7,6 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"neomovies-api/pkg/middleware"
"neomovies-api/pkg/models" "neomovies-api/pkg/models"
"neomovies-api/pkg/services" "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) { func (h *MovieHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"]) id, err := strconv.Atoi(vars["id"])
@@ -284,11 +215,11 @@ func getIntQuery(r *http.Request, key string, defaultValue int) int {
if str == "" { if str == "" {
return defaultValue return defaultValue
} }
value, err := strconv.Atoi(str) value, err := strconv.Atoi(str)
if err != nil { if err != nil {
return defaultValue return defaultValue
} }
return value return value
} }

View File

@@ -9,6 +9,7 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"neomovies-api/pkg/config" "neomovies-api/pkg/config"

View File

@@ -16,12 +16,9 @@ type ReactionsHandler struct {
} }
func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler { func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler {
return &ReactionsHandler{ return &ReactionsHandler{reactionsService: reactionsService}
reactionsService: reactionsService,
}
} }
// Получить счетчики реакций для медиа (публичный эндпоинт)
func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) { func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
mediaType := vars["mediaType"] mediaType := vars["mediaType"]
@@ -42,7 +39,6 @@ func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Requ
json.NewEncoder(w).Encode(counts) json.NewEncoder(w).Encode(counts)
} }
// Получить реакцию текущего пользователя (требует авторизации)
func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) { func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context()) userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok { if !ok {
@@ -59,21 +55,20 @@ func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request)
return return
} }
reaction, err := h.reactionsService.GetUserReaction(userID, mediaType, mediaID) reactionType, err := h.reactionsService.GetMyReaction(userID, mediaType, mediaID)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if reaction == nil { if reactionType == "" {
json.NewEncoder(w).Encode(map[string]interface{}{}) json.NewEncoder(w).Encode(map[string]interface{}{})
} else { } 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) { func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context()) userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok { if !ok {
@@ -93,31 +88,24 @@ func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) {
var request struct { var request struct {
Type string `json:"type"` Type string `json:"type"`
} }
if err := json.NewDecoder(r.Body).Decode(&request); err != nil { if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) http.Error(w, "Invalid request body", http.StatusBadRequest)
return return
} }
if request.Type == "" { if request.Type == "" {
http.Error(w, "Reaction type is required", http.StatusBadRequest) http.Error(w, "Reaction type is required", http.StatusBadRequest)
return return
} }
err := h.reactionsService.SetUserReaction(userID, mediaType, mediaID, request.Type) if err := h.reactionsService.SetReaction(userID, mediaType, mediaID, request.Type); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{ json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction set successfully"})
Success: true,
Message: "Reaction set successfully",
})
} }
// Удалить реакцию пользователя (требует авторизации)
func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request) { func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context()) userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok { if !ok {
@@ -134,20 +122,15 @@ func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request
return return
} }
err := h.reactionsService.RemoveUserReaction(userID, mediaType, mediaID) if err := h.reactionsService.RemoveReaction(userID, mediaType, mediaID); err != nil {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{ json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction removed successfully"})
Success: true,
Message: "Reaction removed successfully",
})
} }
// Получить все реакции пользователя (требует авторизации)
func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request) { func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context()) userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok { if !ok {
@@ -164,8 +147,5 @@ func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{ json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: reactions})
Success: true, }
Data: reactions,
})
}

View File

@@ -42,4 +42,4 @@ func (h *SearchHandler) MultiSearch(w http.ResponseWriter, r *http.Request) {
Success: true, Success: true,
Data: results, Data: results,
}) })
} }

View File

@@ -123,12 +123,12 @@ func (h *TorrentsHandler) SearchTorrents(w http.ResponseWriter, r *http.Request)
// Группируем сначала по сезонам, затем по качеству внутри каждого сезона // Группируем сначала по сезонам, затем по качеству внутри каждого сезона
seasonGroups := h.torrentService.GroupBySeason(results.Results) seasonGroups := h.torrentService.GroupBySeason(results.Results)
finalGroups := make(map[string]map[string][]models.TorrentResult) finalGroups := make(map[string]map[string][]models.TorrentResult)
for season, torrents := range seasonGroups { for season, torrents := range seasonGroups {
qualityGroups := h.torrentService.GroupByQuality(torrents) qualityGroups := h.torrentService.GroupByQuality(torrents)
finalGroups[season] = qualityGroups finalGroups[season] = qualityGroups
} }
response["grouped"] = true response["grouped"] = true
response["groups"] = finalGroups response["groups"] = finalGroups
} else if options.GroupByQuality { } else if options.GroupByQuality {
@@ -364,4 +364,4 @@ func (h *TorrentsHandler) SearchByQuery(w http.ResponseWriter, r *http.Request)
Success: true, Success: true,
Data: response, Data: response,
}) })
} }

View File

@@ -203,4 +203,4 @@ func (h *TVHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
Success: true, Success: true,
Data: externalIDs, Data: externalIDs,
}) })
} }

View File

@@ -60,4 +60,4 @@ func JWTAuth(secret string) func(http.Handler) http.Handler {
func GetUserIDFromContext(ctx context.Context) (string, bool) { func GetUserIDFromContext(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(UserIDKey).(string) userID, ok := ctx.Value(UserIDKey).(string)
return userID, ok return userID, ok
} }

24
pkg/models/favorite.go Normal file
View File

@@ -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"`
}

View File

@@ -1,28 +1,45 @@
package models 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 { type Movie struct {
ID int `json:"id"` ID int `json:"id"`
Title string `json:"title"` Title string `json:"title"`
OriginalTitle string `json:"original_title"` OriginalTitle string `json:"original_title"`
Overview string `json:"overview"` Overview string `json:"overview"`
PosterPath string `json:"poster_path"` PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"` BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
GenreIDs []int `json:"genre_ids"` GenreIDs []int `json:"genre_ids"`
Genres []Genre `json:"genres"` Genres []Genre `json:"genres"`
VoteAverage float64 `json:"vote_average"` VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"` VoteCount int `json:"vote_count"`
Popularity float64 `json:"popularity"` Popularity float64 `json:"popularity"`
Adult bool `json:"adult"` Adult bool `json:"adult"`
Video bool `json:"video"` Video bool `json:"video"`
OriginalLanguage string `json:"original_language"` OriginalLanguage string `json:"original_language"`
Runtime int `json:"runtime,omitempty"` Runtime int `json:"runtime,omitempty"`
Budget int64 `json:"budget,omitempty"` Budget int64 `json:"budget,omitempty"`
Revenue int64 `json:"revenue,omitempty"` Revenue int64 `json:"revenue,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Tagline string `json:"tagline,omitempty"` Tagline string `json:"tagline,omitempty"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
IMDbID string `json:"imdb_id,omitempty"` IMDbID string `json:"imdb_id,omitempty"`
BelongsToCollection *Collection `json:"belongs_to_collection,omitempty"` BelongsToCollection *Collection `json:"belongs_to_collection,omitempty"`
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"` ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"` ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
@@ -30,29 +47,29 @@ type Movie struct {
} }
type TVShow struct { type TVShow struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
OriginalName string `json:"original_name"` OriginalName string `json:"original_name"`
Overview string `json:"overview"` Overview string `json:"overview"`
PosterPath string `json:"poster_path"` PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"` BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"` FirstAirDate string `json:"first_air_date"`
LastAirDate string `json:"last_air_date"` LastAirDate string `json:"last_air_date"`
GenreIDs []int `json:"genre_ids"` GenreIDs []int `json:"genre_ids"`
Genres []Genre `json:"genres"` Genres []Genre `json:"genres"`
VoteAverage float64 `json:"vote_average"` VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"` VoteCount int `json:"vote_count"`
Popularity float64 `json:"popularity"` Popularity float64 `json:"popularity"`
OriginalLanguage string `json:"original_language"` OriginalLanguage string `json:"original_language"`
OriginCountry []string `json:"origin_country"` OriginCountry []string `json:"origin_country"`
NumberOfEpisodes int `json:"number_of_episodes,omitempty"` NumberOfEpisodes int `json:"number_of_episodes,omitempty"`
NumberOfSeasons int `json:"number_of_seasons,omitempty"` NumberOfSeasons int `json:"number_of_seasons,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
Homepage string `json:"homepage,omitempty"` Homepage string `json:"homepage,omitempty"`
InProduction bool `json:"in_production,omitempty"` InProduction bool `json:"in_production,omitempty"`
Languages []string `json:"languages,omitempty"` Languages []string `json:"languages,omitempty"`
Networks []Network `json:"networks,omitempty"` Networks []Network `json:"networks,omitempty"`
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"` ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"` ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"` SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"`
@@ -63,23 +80,23 @@ type TVShow struct {
// MultiSearchResult для мультипоиска // MultiSearchResult для мультипоиска
type MultiSearchResult struct { type MultiSearchResult struct {
ID int `json:"id"` ID int `json:"id"`
MediaType string `json:"media_type"` // "movie" или "tv" MediaType string `json:"media_type"` // "movie" или "tv"
Title string `json:"title,omitempty"` // для фильмов Title string `json:"title,omitempty"` // для фильмов
Name string `json:"name,omitempty"` // для сериалов Name string `json:"name,omitempty"` // для сериалов
OriginalTitle string `json:"original_title,omitempty"` OriginalTitle string `json:"original_title,omitempty"`
OriginalName string `json:"original_name,omitempty"` OriginalName string `json:"original_name,omitempty"`
Overview string `json:"overview"` Overview string `json:"overview"`
PosterPath string `json:"poster_path"` PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"` BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date,omitempty"` // для фильмов ReleaseDate string `json:"release_date,omitempty"` // для фильмов
FirstAirDate string `json:"first_air_date,omitempty"` // для сериалов FirstAirDate string `json:"first_air_date,omitempty"` // для сериалов
GenreIDs []int `json:"genre_ids"` GenreIDs []int `json:"genre_ids"`
VoteAverage float64 `json:"vote_average"` VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"` VoteCount int `json:"vote_count"`
Popularity float64 `json:"popularity"` Popularity float64 `json:"popularity"`
Adult bool `json:"adult"` Adult bool `json:"adult"`
OriginalLanguage string `json:"original_language"` OriginalLanguage string `json:"original_language"`
OriginCountry []string `json:"origin_country,omitempty"` OriginCountry []string `json:"origin_country,omitempty"`
} }
@@ -159,6 +176,31 @@ type Season struct {
SeasonNumber int `json:"season_number"` 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 { type TMDBResponse struct {
Page int `json:"page"` Page int `json:"page"`
Results []Movie `json:"results"` Results []Movie `json:"results"`
@@ -174,12 +216,12 @@ type TMDBTVResponse struct {
} }
type SearchParams struct { type SearchParams struct {
Query string `json:"query"` Query string `json:"query"`
Page int `json:"page"` Page int `json:"page"`
Language string `json:"language"` Language string `json:"language"`
Region string `json:"region"` Region string `json:"region"`
Year int `json:"year"` Year int `json:"year"`
PrimaryReleaseYear int `json:"primary_release_year"` PrimaryReleaseYear int `json:"primary_release_year"`
} }
type APIResponse struct { type APIResponse struct {
@@ -191,23 +233,23 @@ type APIResponse struct {
// Модели для торрентов // Модели для торрентов
type TorrentResult struct { type TorrentResult struct {
Title string `json:"title"` Title string `json:"title"`
Tracker string `json:"tracker"` Tracker string `json:"tracker"`
Size string `json:"size"` Size string `json:"size"`
Seeders int `json:"seeders"` Seeders int `json:"seeders"`
Peers int `json:"peers"` Peers int `json:"peers"`
Leechers int `json:"leechers"` Leechers int `json:"leechers"`
Quality string `json:"quality"` Quality string `json:"quality"`
Voice []string `json:"voice,omitempty"` Voice []string `json:"voice,omitempty"`
Types []string `json:"types,omitempty"` Types []string `json:"types,omitempty"`
Seasons []int `json:"seasons,omitempty"` Seasons []int `json:"seasons,omitempty"`
Category string `json:"category"` Category string `json:"category"`
MagnetLink string `json:"magnet"` MagnetLink string `json:"magnet"`
TorrentLink string `json:"torrent_link,omitempty"` TorrentLink string `json:"torrent_link,omitempty"`
Details string `json:"details,omitempty"` Details string `json:"details,omitempty"`
PublishDate string `json:"publish_date"` PublishDate string `json:"publish_date"`
AddedDate string `json:"added_date,omitempty"` AddedDate string `json:"added_date,omitempty"`
Source string `json:"source"` Source string `json:"source"`
} }
type TorrentSearchResponse struct { type TorrentSearchResponse struct {
@@ -222,16 +264,16 @@ type RedAPIResponse struct {
} }
type RedAPITorrent struct { type RedAPITorrent struct {
Title string `json:"Title"` Title string `json:"Title"`
Tracker string `json:"Tracker"` Tracker string `json:"Tracker"`
Size interface{} `json:"Size"` // Может быть string или number Size interface{} `json:"Size"` // Может быть string или number
Seeders int `json:"Seeders"` Seeders int `json:"Seeders"`
Peers int `json:"Peers"` Peers int `json:"Peers"`
MagnetUri string `json:"MagnetUri"` MagnetUri string `json:"MagnetUri"`
PublishDate string `json:"PublishDate"` PublishDate string `json:"PublishDate"`
CategoryDesc string `json:"CategoryDesc"` CategoryDesc string `json:"CategoryDesc"`
Details string `json:"Details"` Details string `json:"Details"`
Info *RedAPITorrentInfo `json:"Info,omitempty"` Info *RedAPITorrentInfo `json:"Info,omitempty"`
} }
type RedAPITorrentInfo struct { type RedAPITorrentInfo struct {
@@ -276,11 +318,11 @@ type PlayerResponse struct {
// Модели для реакций // Модели для реакций
type Reaction struct { type Reaction struct {
ID string `json:"id" bson:"_id,omitempty"` ID string `json:"id" bson:"_id,omitempty"`
UserID string `json:"userId" bson:"userId"` UserID string `json:"userId" bson:"userId"`
MediaID string `json:"mediaId" bson:"mediaId"` MediaID string `json:"mediaId" bson:"mediaId"`
Type string `json:"type" bson:"type"` Type string `json:"type" bson:"type"`
Created string `json:"created" bson:"created"` Created string `json:"created" bson:"created"`
} }
type ReactionCounts struct { type ReactionCounts struct {
@@ -289,4 +331,4 @@ type ReactionCounts struct {
Think int `json:"think"` Think int `json:"think"`
Bore int `json:"bore"` Bore int `json:"bore"`
Shit int `json:"shit"` Shit int `json:"shit"`
} }

View File

@@ -7,19 +7,22 @@ import (
) )
type User struct { type User struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
Email string `json:"email" bson:"email" validate:"required,email"` Email string `json:"email" bson:"email" validate:"required,email"`
Password string `json:"-" bson:"password" validate:"required,min=6"` Password string `json:"-" bson:"password" validate:"required,min=6"`
Name string `json:"name" bson:"name" validate:"required"` Name string `json:"name" bson:"name" validate:"required"`
Avatar string `json:"avatar" bson:"avatar"` Avatar string `json:"avatar" bson:"avatar"`
Favorites []string `json:"favorites" bson:"favorites"` Favorites []string `json:"favorites" bson:"favorites"`
Verified bool `json:"verified" bson:"verified"` Verified bool `json:"verified" bson:"verified"`
VerificationCode string `json:"-" bson:"verificationCode,omitempty"` VerificationCode string `json:"-" bson:"verificationCode,omitempty"`
VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"` VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"`
IsAdmin bool `json:"isAdmin" bson:"isAdmin"` IsAdmin bool `json:"isAdmin" bson:"isAdmin"`
AdminVerified bool `json:"adminVerified" bson:"adminVerified"` AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
CreatedAt time.Time `json:"created_at" bson:"createdAt"` CreatedAt time.Time `json:"created_at" bson:"createdAt"`
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"` 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 { type LoginRequest struct {
@@ -34,8 +37,9 @@ type RegisterRequest struct {
} }
type AuthResponse struct { type AuthResponse struct {
Token string `json:"token"` Token string `json:"token"`
User User `json:"user"` RefreshToken string `json:"refreshToken"`
User User `json:"user"`
} }
type VerifyEmailRequest struct { type VerifyEmailRequest struct {
@@ -45,4 +49,21 @@ type VerifyEmailRequest struct {
type ResendCodeRequest struct { type ResendCodeRequest struct {
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
} }
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"`
}

View File

@@ -12,16 +12,16 @@ func RequestMonitor() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() start := time.Now()
// Создаем wrapper для ResponseWriter чтобы получить статус код // Создаем wrapper для ResponseWriter чтобы получить статус код
ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
// Выполняем запрос // Выполняем запрос
next.ServeHTTP(ww, r) next.ServeHTTP(ww, r)
// Вычисляем время выполнения // Вычисляем время выполнения
duration := time.Since(start) duration := time.Since(start)
// Форматируем URL (обрезаем если слишком длинный) // Форматируем URL (обрезаем если слишком длинный)
url := r.URL.Path url := r.URL.Path
if r.URL.RawQuery != "" { if r.URL.RawQuery != "" {
@@ -30,11 +30,11 @@ func RequestMonitor() func(http.Handler) http.Handler {
if len(url) > 60 { if len(url) > 60 {
url = url[:57] + "..." url = url[:57] + "..."
} }
// Определяем цвет статуса // Определяем цвет статуса
statusColor := getStatusColor(ww.statusCode) statusColor := getStatusColor(ww.statusCode)
methodColor := getMethodColor(r.Method) methodColor := getMethodColor(r.Method)
// Выводим информацию о запросе // Выводим информацию о запросе
fmt.Printf("\033[2K\r%s%-6s\033[0m %s%-3d\033[0m │ %-60s │ %6.2fms\n", fmt.Printf("\033[2K\r%s%-6s\033[0m %s%-3d\033[0m │ %-60s │ %6.2fms\n",
methodColor, r.Method, methodColor, r.Method,
@@ -88,4 +88,4 @@ func getMethodColor(method string) string {
default: default:
return "\033[37m" // Белый return "\033[37m" // Белый
} }
} }

View File

@@ -4,159 +4,240 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"math/rand" "math/rand"
"net/http"
"net/url"
"sync"
"time" "time"
"encoding/json"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
"go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"neomovies-api/pkg/models" "neomovies-api/pkg/models"
) )
// AuthService contains the database connection, JWT secret, and email service.
type AuthService struct { type AuthService struct {
db *mongo.Database db *mongo.Database
jwtSecret string jwtSecret string
emailService *EmailService emailService *EmailService
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{ service := &AuthService{
db: db, db: db,
jwtSecret: jwtSecret, jwtSecret: jwtSecret,
emailService: emailService, emailService: emailService,
baseURL: baseURL,
googleClientID: googleClientID,
googleClientSecret: googleClientSecret,
googleRedirectURL: googleRedirectURL,
frontendURL: frontendURL,
} }
// Запускаем тест подключения к базе данных
go service.testDatabaseConnection()
return service return service
} }
// testDatabaseConnection тестирует подключение к базе данных и выводит информацию о пользователях func (s *AuthService) googleOAuthConfig() *oauth2.Config {
func (s *AuthService) testDatabaseConnection() { redirectURL := s.googleRedirectURL
ctx := context.Background() if redirectURL == "" && s.baseURL != "" {
redirectURL = fmt.Sprintf("%s/api/v1/auth/google/callback", s.baseURL)
fmt.Println("=== DATABASE CONNECTION TEST ===")
// Проверяем подключение
err := s.db.Client().Ping(ctx, nil)
if err != nil {
fmt.Printf("❌ Database connection failed: %v\n", err)
return
} }
return &oauth2.Config{
fmt.Printf("✅ Database connection successful\n") ClientID: s.googleClientID,
fmt.Printf("📊 Database name: %s\n", s.db.Name()) ClientSecret: s.googleClientSecret,
RedirectURL: redirectURL,
// Получаем список всех коллекций Scopes: []string{"openid", "email", "profile"},
collections, err := s.db.ListCollectionNames(ctx, bson.M{}) Endpoint: google.Endpoint,
if err != nil {
fmt.Printf("❌ Failed to list collections: %v\n", err)
return
} }
fmt.Printf("📁 Available collections: %v\n", collections)
// Проверяем коллекцию users
collection := s.db.Collection("users")
// Подсчитываем количество документов
count, err := collection.CountDocuments(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to count users: %v\n", err)
return
}
fmt.Printf("👥 Total users in database: %d\n", count)
if count > 0 {
// Показываем всех пользователей
cursor, err := collection.Find(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to find users: %v\n", err)
return
}
defer cursor.Close(ctx)
var users []bson.M
if err := cursor.All(ctx, &users); err != nil {
fmt.Printf("❌ Failed to decode users: %v\n", err)
return
}
fmt.Printf("📋 All users in database:\n")
for i, user := range users {
fmt.Printf(" %d. Email: %s, Name: %s, Verified: %v\n",
i+1,
user["email"],
user["name"],
user["verified"])
}
// Тестируем поиск конкретного пользователя
fmt.Printf("\n🔍 Testing specific user search:\n")
testEmails := []string{"neo.movies.mail@gmail.com", "fenixoffc@gmail.com", "test@example.com"}
for _, email := range testEmails {
var user bson.M
err := collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
if err != nil {
fmt.Printf(" ❌ User %s: NOT FOUND (%v)\n", email, err)
} else {
fmt.Printf(" ✅ User %s: FOUND (Name: %s, Verified: %v)\n",
email,
user["name"],
user["verified"])
}
}
}
fmt.Println("=== END DATABASE TEST ===")
} }
// Генерация 6-значного кода func (s *AuthService) 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 { func (s *AuthService) generateVerificationCode() string {
return fmt.Sprintf("%06d", rand.Intn(900000)+100000) return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
} }
// Register registers a new user.
func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) { func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users") collection := s.db.Collection("users")
// Проверяем, не существует ли уже пользователь с таким email
var existingUser models.User var existingUser models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser) err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser)
if err == nil { if err == nil {
return nil, errors.New("email already registered") return nil, errors.New("email already registered")
} }
// Хешируем пароль
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Генерируем код верификации
code := s.generateVerificationCode() code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute) // 10 минут codeExpires := time.Now().Add(10 * time.Minute)
// Создаем нового пользователя (НЕ ВЕРИФИЦИРОВАННОГО)
user := models.User{ user := models.User{
ID: primitive.NewObjectID(), ID: primitive.NewObjectID(),
Email: req.Email, Email: req.Email,
Password: string(hashedPassword), Password: string(hashedPassword),
Name: req.Name, Name: req.Name,
Favorites: []string{}, Favorites: []string{},
Verified: false, Verified: false,
VerificationCode: code, VerificationCode: code,
VerificationExpires: codeExpires, VerificationExpires: codeExpires,
IsAdmin: false, IsAdmin: false,
AdminVerified: false, AdminVerified: false,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
_, err = collection.InsertOne(context.Background(), user) _, err = collection.InsertOne(context.Background(), user)
@@ -164,7 +245,6 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface
return nil, err return nil, err
} }
// Отправляем код верификации на email
if s.emailService != nil { if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code) go s.emailService.SendVerificationEmail(user.Email, code)
} }
@@ -175,44 +255,43 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface
}, nil }, 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") collection := s.db.Collection("users")
fmt.Printf("🔍 Login attempt for email: %s\n", req.Email)
fmt.Printf("📊 Database name: %s\n", s.db.Name())
fmt.Printf("📁 Collection name: %s\n", collection.Name())
// Находим пользователя по email (точно как в JavaScript)
var user models.User var user models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user) err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
if err != nil { if err != nil {
fmt.Printf("❌ User not found: %v\n", err)
return nil, errors.New("User not found") return nil, errors.New("User not found")
} }
// Проверяем верификацию email (точно как в JavaScript)
if !user.Verified { if !user.Verified {
return nil, errors.New("Account not activated. Please verify your email.") return nil, errors.New("Account not activated. Please verify your email.")
} }
// Проверяем пароль (точно как в JavaScript)
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
if err != nil { if err != nil {
return nil, errors.New("Invalid password") return nil, errors.New("Invalid password")
} }
// Генерируем JWT токен tokenPair, err := s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress)
token, err := s.generateJWT(user.ID.Hex())
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &models.AuthResponse{ return &models.AuthResponse{
Token: token, Token: tokenPair.AccessToken,
User: user, RefreshToken: tokenPair.RefreshToken,
User: user,
}, nil }, 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) { func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
collection := s.db.Collection("users") collection := s.db.Collection("users")
@@ -230,6 +309,7 @@ func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
return &user, nil return &user, nil
} }
// UpdateUser updates a user's information.
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) { func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
collection := s.db.Collection("users") collection := s.db.Collection("users")
@@ -252,10 +332,11 @@ func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, e
return s.GetUserByID(userID) return s.GetUserByID(userID)
} }
// generateJWT generates a new JWT for a given user ID.
func (s *AuthService) generateJWT(userID string) (string, error) { func (s *AuthService) generateJWT(userID string) (string, error) {
claims := jwt.MapClaims{ claims := jwt.MapClaims{
"user_id": userID, "user_id": userID,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 дней "exp": time.Now().Add(time.Hour * 1).Unix(), // Сократил время жизни до 1 часа
"iat": time.Now().Unix(), "iat": time.Now().Unix(),
"jti": uuid.New().String(), "jti": uuid.New().String(),
} }
@@ -264,7 +345,159 @@ func (s *AuthService) generateJWT(userID string) (string, error) {
return token.SignedString([]byte(s.jwtSecret)) 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) { func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users") collection := s.db.Collection("users")
@@ -281,12 +514,10 @@ func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]int
}, nil }, nil
} }
// Проверяем код и срок действия
if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) { if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) {
return nil, errors.New("invalid or expired verification code") return nil, errors.New("invalid or expired verification code")
} }
// Верифицируем пользователя
_, err = collection.UpdateOne( _, err = collection.UpdateOne(
context.Background(), context.Background(),
bson.M{"email": req.Email}, bson.M{"email": req.Email},
@@ -308,7 +539,7 @@ func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]int
}, nil }, nil
} }
// Повторная отправка кода верификации // ResendVerificationCode sends a new verification email.
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) { func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users") collection := s.db.Collection("users")
@@ -322,11 +553,9 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
return nil, errors.New("email already verified") return nil, errors.New("email already verified")
} }
// Генерируем новый код
code := s.generateVerificationCode() code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute) codeExpires := time.Now().Add(10 * time.Minute)
// Обновляем код в базе
_, err = collection.UpdateOne( _, err = collection.UpdateOne(
context.Background(), context.Background(),
bson.M{"email": req.Email}, bson.M{"email": req.Email},
@@ -341,7 +570,6 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
return nil, err return nil, err
} }
// Отправляем новый код на email
if s.emailService != nil { if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code) go s.emailService.SendVerificationEmail(user.Email, code)
} }
@@ -350,4 +578,77 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
"success": true, "success": true,
"message": "Verification code sent to your email", "message": "Verification code sent to your email",
}, nil }, nil
} }
// DeleteAccount deletes a user and all associated data.
func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error {
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return fmt.Errorf("invalid user ID format: %w", err)
}
// Step 1: Find user reactions and remove them from cub.rip
if s.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
}

View File

@@ -98,7 +98,7 @@ func (s *EmailService) SendVerificationEmail(userEmail, code string) error {
func (s *EmailService) SendPasswordResetEmail(userEmail, resetToken string) error { func (s *EmailService) SendPasswordResetEmail(userEmail, resetToken string) error {
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, resetToken) resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, resetToken)
options := &EmailOptions{ options := &EmailOptions{
To: []string{userEmail}, To: []string{userEmail},
Subject: "Сброс пароля Neo Movies", Subject: "Сброс пароля Neo Movies",
@@ -147,4 +147,4 @@ func (s *EmailService) SendMovieRecommendationEmail(userEmail, userName string,
} }
return s.SendEmail(options) return s.SendEmail(options)
} }

184
pkg/services/favorites.go Normal file
View File

@@ -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
}

View File

@@ -1,23 +1,17 @@
package services package services
import ( import (
"context"
"strconv"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models" "neomovies-api/pkg/models"
) )
type MovieService struct { type MovieService struct {
db *mongo.Database
tmdb *TMDBService tmdb *TMDBService
} }
func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService { func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService {
return &MovieService{ return &MovieService{
db: db,
tmdb: tmdb, tmdb: tmdb,
} }
} }
@@ -54,57 +48,6 @@ func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBRe
return s.tmdb.GetSimilarMovies(id, page, language) 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) { func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
return s.tmdb.GetMovieExternalIDs(id) return s.tmdb.GetMovieExternalIDs(id)
} }

View File

@@ -12,6 +12,7 @@ import (
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/options"
"neomovies-api/pkg/config"
"neomovies-api/pkg/models" "neomovies-api/pkg/models"
) )
@@ -27,17 +28,15 @@ func NewReactionsService(db *mongo.Database) *ReactionsService {
} }
} }
const CUB_API_URL = "https://cub.rip/api" var validReactions = []string{"fire", "nice", "think", "bore", "shit"}
var VALID_REACTIONS = []string{"fire", "nice", "think", "bore", "shit"}
// Получить счетчики реакций для медиа из внешнего API (cub.rip) // Получить счетчики реакций для медиа из внешнего API (cub.rip)
func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) { func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) {
cubID := fmt.Sprintf("%s_%s", mediaType, mediaID) 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 { if err != nil {
return &models.ReactionCounts{}, nil // Возвращаем пустые счетчики при ошибке return &models.ReactionCounts{}, nil
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -61,7 +60,6 @@ func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models
return &models.ReactionCounts{}, nil return &models.ReactionCounts{}, nil
} }
// Преобразуем в нашу структуру
counts := &models.ReactionCounts{} counts := &models.ReactionCounts{}
for _, reaction := range response.Result { for _, reaction := range response.Result {
switch reaction.Type { switch reaction.Type {
@@ -81,76 +79,60 @@ func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models
return counts, nil return counts, nil
} }
// Получить реакцию пользователя для медиа func (s *ReactionsService) GetMyReaction(userID, mediaType, mediaID string) (string, error) {
func (s *ReactionsService) GetUserReaction(userID, mediaType, mediaID string) (*models.Reaction, error) {
collection := s.db.Collection("reactions") collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) ctx := context.Background()
var reaction models.Reaction var result struct {
err := collection.FindOne(context.Background(), bson.M{ Type string `bson:"type"`
"userId": userID,
"mediaId": fullMediaID,
}).Decode(&reaction)
if err == mongo.ErrNoDocuments {
return nil, nil // Реакции нет
} }
err := collection.FindOne(ctx, bson.M{
return &reaction, err "userId": userID,
} "mediaType": mediaType,
"mediaId": mediaID,
// Установить реакцию пользователя }).Decode(&result)
func (s *ReactionsService) SetUserReaction(userID, mediaType, mediaID, reactionType string) error {
// Проверяем валидность типа реакции
if !s.isValidReactionType(reactionType) {
return fmt.Errorf("invalid reaction type: %s", reactionType)
}
collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
// Создаем или обновляем реакцию
filter := bson.M{
"userId": userID,
"mediaId": fullMediaID,
}
reaction := models.Reaction{
UserID: userID,
MediaID: fullMediaID,
Type: reactionType,
Created: time.Now().Format(time.RFC3339),
}
update := bson.M{
"$set": reaction,
}
upsert := true
_, err := collection.UpdateOne(context.Background(), filter, update, &options.UpdateOptions{
Upsert: &upsert,
})
if err != nil { if err != nil {
return err if err == mongo.ErrNoDocuments {
return "", nil
}
return "", err
} }
return result.Type, nil
// Отправляем реакцию в cub.rip API
go s.sendReactionToCub(fullMediaID, reactionType)
return nil
} }
// Удалить реакцию пользователя func (s *ReactionsService) SetReaction(userID, mediaType, mediaID, reactionType string) error {
func (s *ReactionsService) RemoveUserReaction(userID, mediaType, mediaID string) error { if !s.isValidReactionType(reactionType) {
collection := s.db.Collection("reactions") return fmt.Errorf("invalid reaction type")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) }
_, err := collection.DeleteOne(context.Background(), bson.M{ collection := s.db.Collection("reactions")
"userId": userID, ctx := context.Background()
"mediaId": fullMediaID,
_, 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 return err
} }
@@ -174,7 +156,7 @@ func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models.
} }
func (s *ReactionsService) isValidReactionType(reactionType string) bool { func (s *ReactionsService) isValidReactionType(reactionType string) bool {
for _, valid := range VALID_REACTIONS { for _, valid := range validReactions {
if valid == reactionType { if valid == reactionType {
return true return true
} }
@@ -184,9 +166,8 @@ func (s *ReactionsService) isValidReactionType(reactionType string) bool {
// Отправка реакции в cub.rip API (асинхронно) // Отправка реакции в cub.rip API (асинхронно)
func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) { func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
// Формируем запрос к cub.rip API url := fmt.Sprintf("%s/reactions/set", config.CubAPIBaseURL)
url := fmt.Sprintf("%s/reactions/set", CUB_API_URL)
data := map[string]string{ data := map[string]string{
"mediaId": mediaID, "mediaId": mediaID,
"type": reactionType, "type": reactionType,
@@ -197,16 +178,13 @@ func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
return return
} }
// В данном случае мы отправляем простой POST запрос
// В будущем можно доработать для отправки JSON данных
resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType)) resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType))
if err != nil { if err != nil {
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
// Логируем результат (в продакшене лучше использовать структурированное логирование)
if resp.StatusCode == http.StatusOK { if resp.StatusCode == http.StatusOK {
fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType) fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType)
} }
} }

View File

@@ -52,23 +52,23 @@ func (s *TMDBService) SearchMovies(query string, page int, language, region stri
params.Set("query", query) params.Set("query", query)
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
params.Set("include_adult", "false") params.Set("include_adult", "false")
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } else {
params.Set("language", "ru-RU") params.Set("language", "ru-RU")
} }
if region != "" { if region != "" {
params.Set("region", region) params.Set("region", region)
} }
if year > 0 { if year > 0 {
params.Set("year", strconv.Itoa(year)) params.Set("year", strconv.Itoa(year))
} }
endpoint := fmt.Sprintf("%s/search/movie?%s", s.baseURL, params.Encode()) endpoint := fmt.Sprintf("%s/search/movie?%s", s.baseURL, params.Encode())
var response models.TMDBResponse var response models.TMDBResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err return &response, err
@@ -119,24 +119,29 @@ func (s *TMDBService) SearchMulti(query string, page int, language string) (*mod
return &response, nil 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) { func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("query", query) params.Set("query", query)
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
params.Set("include_adult", "false") params.Set("include_adult", "false")
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } else {
params.Set("language", "ru-RU") params.Set("language", "ru-RU")
} }
if firstAirDateYear > 0 { if firstAirDateYear > 0 {
params.Set("first_air_date_year", strconv.Itoa(firstAirDateYear)) params.Set("first_air_date_year", strconv.Itoa(firstAirDateYear))
} }
endpoint := fmt.Sprintf("%s/search/tv?%s", s.baseURL, params.Encode()) endpoint := fmt.Sprintf("%s/search/tv?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) {
params := url.Values{} params := url.Values{}
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode())
var movie models.Movie var movie models.Movie
err := s.makeRequest(endpoint, &movie) err := s.makeRequest(endpoint, &movie)
return &movie, err 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) { func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) {
params := url.Values{} params := url.Values{}
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode())
var tvShow models.TVShow var tvShow models.TVShow
err := s.makeRequest(endpoint, &tvShow) err := s.makeRequest(endpoint, &tvShow)
return &tvShow, err 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) { func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) {
params := url.Values{} params := url.Values{}
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/genre/%s/list?%s", s.baseURL, mediaType, params.Encode())
var response models.GenresResponse var response models.GenresResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err return &response, err
@@ -205,11 +210,11 @@ func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) {
// Объединяем жанры, убирая дубликаты // Объединяем жанры, убирая дубликаты
allGenres := make(map[int]models.Genre) allGenres := make(map[int]models.Genre)
for _, genre := range movieGenres.Genres { for _, genre := range movieGenres.Genres {
allGenres[genre.ID] = genre allGenres[genre.ID] = genre
} }
for _, genre := range tvGenres.Genres { for _, genre := range tvGenres.Genres {
allGenres[genre.ID] = genre 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) { func (s *TMDBService) GetPopularMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } else {
params.Set("language", "ru-RU") params.Set("language", "ru-RU")
} }
if region != "" { if region != "" {
params.Set("region", region) params.Set("region", region)
} }
endpoint := fmt.Sprintf("%s/movie/popular?%s", s.baseURL, params.Encode()) endpoint := fmt.Sprintf("%s/movie/popular?%s", s.baseURL, params.Encode())
var response models.TMDBResponse var response models.TMDBResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } else {
params.Set("language", "ru-RU") params.Set("language", "ru-RU")
} }
if region != "" { if region != "" {
params.Set("region", region) params.Set("region", region)
} }
endpoint := fmt.Sprintf("%s/movie/top_rated?%s", s.baseURL, params.Encode()) endpoint := fmt.Sprintf("%s/movie/top_rated?%s", s.baseURL, params.Encode())
var response models.TMDBResponse var response models.TMDBResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } else {
params.Set("language", "ru-RU") params.Set("language", "ru-RU")
} }
if region != "" { if region != "" {
params.Set("region", region) params.Set("region", region)
} }
endpoint := fmt.Sprintf("%s/movie/upcoming?%s", s.baseURL, params.Encode()) endpoint := fmt.Sprintf("%s/movie/upcoming?%s", s.baseURL, params.Encode())
var response models.TMDBResponse var response models.TMDBResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } else {
params.Set("language", "ru-RU") params.Set("language", "ru-RU")
} }
if region != "" { if region != "" {
params.Set("region", region) params.Set("region", region)
} }
endpoint := fmt.Sprintf("%s/movie/now_playing?%s", s.baseURL, params.Encode()) endpoint := fmt.Sprintf("%s/movie/now_playing?%s", s.baseURL, params.Encode())
var response models.TMDBResponse var response models.TMDBResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/movie/%d/recommendations?%s", s.baseURL, id, params.Encode())
var response models.TMDBResponse var response models.TMDBResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/movie/%d/similar?%s", s.baseURL, id, params.Encode())
var response models.TMDBResponse var response models.TMDBResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/tv/on_the_air?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/tv/%d/recommendations?%s", s.baseURL, id, params.Encode())
var response models.TMDBTVResponse var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/tv/%d/similar?%s", s.baseURL, id, params.Encode())
var response models.TMDBTVResponse var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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) { func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) {
endpoint := fmt.Sprintf("%s/movie/%d/external_ids", s.baseURL, id) endpoint := fmt.Sprintf("%s/movie/%d/external_ids", s.baseURL, id)
var ids models.ExternalIDs var ids models.ExternalIDs
err := s.makeRequest(endpoint, &ids) err := s.makeRequest(endpoint, &ids)
return &ids, err 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) { func (s *TMDBService) GetTVExternalIDs(id int) (*models.ExternalIDs, error) {
endpoint := fmt.Sprintf("%s/tv/%d/external_ids", s.baseURL, id) endpoint := fmt.Sprintf("%s/tv/%d/external_ids", s.baseURL, id)
var ids models.ExternalIDs var ids models.ExternalIDs
err := s.makeRequest(endpoint, &ids) err := s.makeRequest(endpoint, &ids)
return &ids, err return &ids, err
@@ -464,7 +469,7 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string)
params.Set("page", strconv.Itoa(page)) params.Set("page", strconv.Itoa(page))
params.Set("with_genres", strconv.Itoa(genreID)) params.Set("with_genres", strconv.Itoa(genreID))
params.Set("sort_by", "popularity.desc") params.Set("sort_by", "popularity.desc")
if language != "" { if language != "" {
params.Set("language", language) params.Set("language", language)
} else { } 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()) endpoint := fmt.Sprintf("%s/discover/movie?%s", s.baseURL, params.Encode())
var response models.TMDBResponse var response models.TMDBResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err return &response, err
} }
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
}

View File

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

View File

@@ -52,4 +52,4 @@ func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVRes
func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) { func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
return s.tmdb.GetTVExternalIDs(id) return s.tmdb.GetTVExternalIDs(id)
} }