mirror of
https://gitlab.com/foxixus/neomovies-api.git
synced 2025-10-27 17:38:51 +05:00
Merge branch 'main' into 'feature/add-streaming-players'
# Conflicts: # api/index.go # pkg/handlers/players.go
This commit is contained in:
44
.env.example
44
.env.example
@@ -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
110
README.md
@@ -1,48 +1,16 @@
|
|||||||
# Neo Movies API (Go Version) 🎬
|
# Neo Movies API
|
||||||
|
|
||||||
> Современный API для поиска фильмов и сериалов, портированный с Node.js на Go
|
REST API для поиска и получения информации о фильмах, использующий TMDB API.
|
||||||
|
|
||||||
## 🚀 Особенности
|
## Особенности
|
||||||
|
|
||||||
- ⚡ **Высокая производительность** - написан на Go
|
- Поиск фильмов
|
||||||
- 🔒 **JWT аутентификация** с email верификацией
|
- Информация о фильмах
|
||||||
- 🎭 **TMDB API интеграция** для данных о фильмах/сериалах
|
- Популярные фильмы
|
||||||
- 📧 **Email уведомления** через Gmail SMTP
|
- Топ рейтинговые фильмы
|
||||||
- 🔍 **Полнотекстовый поиск** фильмов и сериалов
|
- Предстоящие фильмы
|
||||||
- ⭐ **Система избранного** для пользователей
|
- 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 # Все мои реакции
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📖 Примеры использования
|
## 📖 Примеры использования
|
||||||
|
|||||||
54
api/index.go
54
api/index.go
@@ -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
4
go.mod
@@ -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
4
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM=
|
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 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
97
main.go
@@ -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)))
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
36
pkg/config/vars.go
Normal 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"
|
||||||
|
)
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
7
pkg/handlers/auth_helpers.go
Normal file
7
pkg/handlers/auth_helpers.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func uuidNew() string { return uuid.New().String() }
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
1060
pkg/handlers/docs.go
1060
pkg/handlers/docs.go
File diff suppressed because it is too large
Load Diff
260
pkg/handlers/favorites.go
Normal file
260
pkg/handlers/favorites.go
Normal 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
|
||||||
|
}
|
||||||
@@ -26,4 +26,4 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var startTime = time.Now()
|
var startTime = time.Now()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -42,4 +42,4 @@ func (h *SearchHandler) MultiSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
Success: true,
|
Success: true,
|
||||||
Data: results,
|
Data: results,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,4 +203,4 @@ func (h *TVHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
|
|||||||
Success: true,
|
Success: true,
|
||||||
Data: externalIDs,
|
Data: externalIDs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
24
pkg/models/favorite.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" // Белый
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
184
pkg/services/favorites.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user