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

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

View File

@@ -1,26 +1,28 @@
# MongoDB Configuration
MONGO_URI=mongodb://localhost:27017/neomovies
# Required
MONGO_URI=
MONGO_DB_NAME=database
TMDB_ACCESS_TOKEN=
JWT_SECRET=
# TMDB API Configuration
TMDB_ACCESS_TOKEN=your_tmdb_access_token_here
# JWT Configuration
JWT_SECRET=your_super_secret_jwt_key_here
# Email Configuration (для уведомлений)
GMAIL_USER=your_gmail@gmail.com
GMAIL_APP_PASSWORD=your_app_specific_password
# Players Configuration
LUMEX_URL=your_lumex_player_url
ALLOHA_TOKEN=your_alloha_token
# Server Configuration
# Service
PORT=3000
BASE_URL=http://localhost:3000
NODE_ENV=development
# Production Configuration (для Vercel)
# MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/neomovies
# BASE_URL=https://your-app.vercel.app
# NODE_ENV=production
# Email (Gmail)
GMAIL_USER=
GMAIL_APP_PASSWORD=
# Players
LUMEX_URL=
ALLOHA_TOKEN=
# Torrents (RedAPI)
REDAPI_BASE_URL=http://redapi.cfhttp.top
REDAPI_KEY=
# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback
FRONTEND_URL=http://localhost:3001

108
README.md
View File

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

View File

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

4
go.mod
View File

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

4
go.sum
View File

@@ -1,3 +1,5 @@
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM=
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06/go.mod h1:/wotfjM8I3m8NuIHPz3S8k+CCYH80EqDT8ZeNLqMQm0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -51,6 +53,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

91
main.go
View File

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

View File

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

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

@@ -0,0 +1,36 @@
package config
const (
// Environment variable keys
EnvTMDBAccessToken = "TMDB_ACCESS_TOKEN"
EnvJWTSecret = "JWT_SECRET"
EnvPort = "PORT"
EnvBaseURL = "BASE_URL"
EnvNodeEnv = "NODE_ENV"
EnvGmailUser = "GMAIL_USER"
EnvGmailPassword = "GMAIL_APP_PASSWORD"
EnvLumexURL = "LUMEX_URL"
EnvAllohaToken = "ALLOHA_TOKEN"
EnvRedAPIBaseURL = "REDAPI_BASE_URL"
EnvRedAPIKey = "REDAPI_KEY"
EnvMongoDBName = "MONGO_DB_NAME"
EnvGoogleClientID = "GOOGLE_CLIENT_ID"
EnvGoogleClientSecret = "GOOGLE_CLIENT_SECRET"
EnvGoogleRedirectURL = "GOOGLE_REDIRECT_URL"
EnvFrontendURL = "FRONTEND_URL"
EnvVibixHost = "VIBIX_HOST"
EnvVibixToken = "VIBIX_TOKEN"
// Default values
DefaultJWTSecret = "your-secret-key"
DefaultPort = "3000"
DefaultBaseURL = "http://localhost:3000"
DefaultNodeEnv = "development"
DefaultRedAPIBase = "http://redapi.cfhttp.top"
DefaultMongoDBName = "database"
DefaultVibixHost = "https://vibix.org"
// Static constants
TMDBImageBaseURL = "https://image.tmdb.org/t/p"
CubAPIBaseURL = "https://cub.rip/api"
)

View File

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

View File

@@ -3,6 +3,8 @@ package handlers
import (
"encoding/json"
"net/http"
"strings"
"time"
"go.mongodb.org/mongo-driver/bson"
@@ -16,9 +18,7 @@ type AuthHandler struct {
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
@@ -36,11 +36,7 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
Message: "User registered successfully",
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "User registered successfully"})
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
@@ -50,23 +46,91 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return
}
response, err := h.authService.Login(req)
// Получаем информацию о клиенте для refresh токена
userAgent := r.Header.Get("User-Agent")
ipAddress := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ipAddress = forwarded
}
response, err := h.authService.LoginWithTokens(req, userAgent, ipAddress)
if err != nil {
// Определяем правильный статус код в зависимости от ошибки
statusCode := http.StatusBadRequest
if err.Error() == "Account not activated. Please verify your email." {
statusCode = http.StatusForbidden // 403 для неверифицированного email
statusCode = http.StatusForbidden
}
http.Error(w, err.Error(), statusCode)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
Message: "Login successful",
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "Login successful"})
}
func (h *AuthHandler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
state := generateState()
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Path: "/", Expires: time.Now().Add(10 * time.Minute)})
url, err := h.authService.GetGoogleLoginURL(state)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
func (h *AuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
state := q.Get("state")
code := q.Get("code")
preferJSON := q.Get("response") == "json" || strings.Contains(r.Header.Get("Accept"), "application/json")
cookie, _ := r.Cookie("oauth_state")
if cookie == nil || cookie.Value != state || code == "" {
if preferJSON {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: "invalid oauth state"})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect("", "invalid_state")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
http.Error(w, "invalid oauth state", http.StatusBadRequest)
return
}
resp, err := h.authService.HandleGoogleCallback(r.Context(), code)
if err != nil {
if preferJSON {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: err.Error()})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect("", "auth_failed")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if preferJSON {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect(resp.Token, "")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"})
}
func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
@@ -83,10 +147,7 @@ func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: user,
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user})
}
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
@@ -102,7 +163,6 @@ func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
return
}
// Удаляем поля, которые нельзя обновлять через этот эндпоинт
delete(updates, "password")
delete(updates, "email")
delete(updates, "_id")
@@ -115,14 +175,25 @@ func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: user,
Message: "Profile updated successfully",
})
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user, Message: "Profile updated successfully"})
}
func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
if err := h.authService.DeleteAccount(r.Context(), userID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Account deleted successfully"})
}
// Верификация email
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
var req models.VerifyEmailRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -140,7 +211,6 @@ func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response)
}
// Повторная отправка кода верификации
func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) {
var req models.ResendCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -157,3 +227,83 @@ func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Requ
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// RefreshToken refreshes an access token using a refresh token
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
var req models.RefreshTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Получаем информацию о клиенте
userAgent := r.Header.Get("User-Agent")
ipAddress := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ipAddress = forwarded
}
tokenPair, err := h.authService.RefreshAccessToken(req.RefreshToken, userAgent, ipAddress)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tokenPair,
Message: "Token refreshed successfully",
})
}
// RevokeRefreshToken revokes a specific refresh token
func (h *AuthHandler) RevokeRefreshToken(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
var req models.RefreshTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
err := h.authService.RevokeRefreshToken(userID, req.RefreshToken)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Refresh token revoked successfully",
})
}
// RevokeAllRefreshTokens revokes all refresh tokens for the current user
func (h *AuthHandler) RevokeAllRefreshTokens(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
err := h.authService.RevokeAllRefreshTokens(userID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "All refresh tokens revoked successfully",
})
}
// helpers
func generateState() string { return uuidNew() }

View File

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

View File

@@ -53,7 +53,7 @@ func (h *CategoriesHandler) GetCategories(w http.ResponseWriter, r *http.Request
})
}
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
func (h *CategoriesHandler) GetMediaByCategory(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
categoryID, err := strconv.Atoi(vars["id"])
if err != nil {
@@ -67,20 +67,46 @@ func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.R
language = "ru-RU"
}
mediaType := r.URL.Query().Get("type")
if mediaType == "" {
mediaType = "movie" // По умолчанию фильмы для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
var data interface{}
var err2 error
if mediaType == "movie" {
// Используем discover API для получения фильмов по жанру
movies, err := h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
data, err2 = h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
} else {
// Используем discover API для получения сериалов по жанру
data, err2 = h.tmdbService.DiscoverTVByGenre(categoryID, page, language)
}
if err2 != nil {
http.Error(w, err2.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
Data: data,
Message: "Media retrieved successfully",
})
}
// Старый метод для обратной совместимости
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
// Просто перенаправляем на новый метод
h.GetMediaByCategory(w, r)
}
func generateSlug(name string) string {
// Простая функция для создания slug из названия
// В реальном проекте стоит использовать более сложную логику

View File

@@ -4,22 +4,18 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"github.com/MarceloPetrucio/go-scalar-api-reference"
)
type DocsHandler struct {
// Убираем статическую спецификацию
}
type DocsHandler struct{}
func NewDocsHandler() *DocsHandler {
return &DocsHandler{}
}
func (h *DocsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Обслуживаем документацию для всех путей
// Это нужно для правильной работы Scalar API Reference
h.ServeDocs(w, r)
}
@@ -28,34 +24,22 @@ func (h *DocsHandler) RedirectToDocs(w http.ResponseWriter, r *http.Request) {
}
func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) {
// Определяем baseURL динамически
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
if r.TLS != nil {
baseURL = fmt.Sprintf("https://%s", r.Host)
} else {
baseURL = fmt.Sprintf("http://%s", r.Host)
}
}
_ = determineBaseURL(r)
// Генерируем спецификацию с правильным URL
spec := getOpenAPISpecWithURL(baseURL)
// Use relative server URL to inherit correct scheme/host from the browser/proxy
spec := getOpenAPISpecWithURL("/")
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With")
json.NewEncoder(w).Encode(spec)
}
func (h *DocsHandler) ServeDocs(w http.ResponseWriter, r *http.Request) {
baseURL := os.Getenv("BASE_URL")
if baseURL == "" {
if r.TLS != nil {
baseURL = fmt.Sprintf("https://%s", r.Host)
} else {
baseURL = fmt.Sprintf("http://%s", r.Host)
}
}
baseURL := determineBaseURL(r)
// Use absolute SpecURL so the library does not try to read a local file path
htmlContent, err := scalar.ApiReferenceHTML(&scalar.Options{
SpecURL: fmt.Sprintf("%s/openapi.json", baseURL),
CustomOptions: scalar.CustomOptions{
@@ -70,9 +54,64 @@ func (h *DocsHandler) ServeDocs(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
fmt.Fprintln(w, htmlContent)
}
func determineBaseURL(r *http.Request) string {
// Prefer proxy headers and request info over environment to avoid wrong scheme on platforms like Vercel
proto := ""
host := r.Host
if fwd := r.Header.Get("Forwarded"); fwd != "" {
for _, part := range strings.Split(fwd, ";") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
key := strings.ToLower(strings.TrimSpace(kv[0]))
val := strings.Trim(strings.TrimSpace(kv[1]), "\"")
switch key {
case "proto":
if proto == "" {
proto = strings.ToLower(val)
}
case "host":
if val != "" {
host = val
}
}
}
}
if proto == "" {
if p := r.Header.Get("X-Forwarded-Proto"); p != "" {
proto = strings.ToLower(strings.TrimSpace(strings.Split(p, ",")[0]))
}
}
if xfh := r.Header.Get("X-Forwarded-Host"); xfh != "" {
host = strings.TrimSpace(strings.Split(xfh, ",")[0])
}
if proto == "" {
if r.TLS != nil {
proto = "https"
} else {
proto = "http"
}
}
if xfp := r.Header.Get("X-Forwarded-Port"); xfp != "" && !strings.Contains(host, ":") {
isDefault := (proto == "http" && xfp == "80") || (proto == "https" && xfp == "443")
if !isDefault {
host = host + ":" + xfp
}
}
return fmt.Sprintf("%s://%s", proto, host)
}
type OpenAPISpec struct {
OpenAPI string `json:"openapi"`
Info Info `json:"info"`
@@ -147,7 +186,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
},
},
},
"/search/multi": map[string]interface{}{
"/api/v1/search/multi": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Мультипоиск",
"description": "Поиск фильмов, сериалов и актеров",
@@ -215,6 +254,58 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
},
},
},
"/api/v1/categories/{id}/media": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Медиа по категории",
"description": "Получение фильмов или сериалов по категории",
"tags": []string{"Categories"},
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"schema": map[string]string{"type": "integer"},
"description": "ID категории",
},
{
"name": "type",
"in": "query",
"required": false,
"schema": map[string]interface{}{
"type": "string",
"enum": []string{"movie", "tv"},
"default": "movie",
},
"description": "Тип медиа: movie или tv",
},
{
"name": "page",
"in": "query",
"required": false,
"schema": map[string]interface{}{
"type": "integer",
"default": 1,
},
"description": "Номер страницы",
},
{
"name": "language",
"in": "query",
"required": false,
"schema": map[string]interface{}{
"type": "string",
"default": "ru-RU",
},
"description": "Язык ответа",
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Медиа по категории",
},
},
},
},
"/api/v1/players/alloha/{imdb_id}": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Плеер Alloha",
@@ -257,6 +348,102 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
},
},
},
"/api/v1/players/vibix/{imdb_id}": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Vibix плеер по IMDb ID",
"description": "Возвращает HTML-страницу с iframe Vibix для указанного IMDb ID",
"tags": []string{"Players"},
"parameters": []map[string]interface{}{
{
"name": "imdb_id",
"in": "path",
"required": true,
"schema": map[string]string{"type": "string"},
"description": "IMDb ID, например tt0133093",
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "HTML со встроенным Vibix плеером",
"content": map[string]interface{}{
"text/html": map[string]interface{}{},
},
},
"404": map[string]interface{}{"description": "Фильм не найден"},
"503": map[string]interface{}{"description": "VIBIX_TOKEN не настроен"},
},
},
},
"/api/v1/webtorrent/player": map[string]interface{}{
"get": map[string]interface{}{
"summary": "WebTorrent плеер",
"description": "Открытие WebTorrent плеера с магнет ссылкой. Плеер работает полностью на стороне клиента.",
"tags": []string{"WebTorrent"},
"parameters": []map[string]interface{}{
{
"name": "magnet",
"in": "query",
"required": false,
"schema": map[string]string{"type": "string"},
"description": "Магнет ссылка торрента",
},
{
"name": "X-Magnet-Link",
"in": "header",
"required": false,
"schema": map[string]string{"type": "string"},
"description": "Магнет ссылка через заголовок (альтернативный способ)",
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "HTML страница с WebTorrent плеером",
"content": map[string]interface{}{
"text/html": map[string]interface{}{
"schema": map[string]string{"type": "string"},
},
},
},
"400": map[string]interface{}{
"description": "Отсутствует магнет ссылка",
},
},
},
},
"/api/v1/webtorrent/metadata": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Метаданные медиа",
"description": "Получение метаданных фильма или сериала по названию для WebTorrent плеера",
"tags": []string{"WebTorrent"},
"parameters": []map[string]interface{}{
{
"name": "query",
"in": "query",
"required": true,
"schema": map[string]string{"type": "string"},
"description": "Название для поиска (извлеченное из торрента)",
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Метаданные найдены",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"$ref": "#/components/schemas/WebTorrentMetadata",
},
},
},
},
"400": map[string]interface{}{
"description": "Отсутствует параметр query",
},
"404": map[string]interface{}{
"description": "Метаданные не найдены",
},
},
},
},
"/api/v1/torrents/search/{imdbId}": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Поиск торрентов",
@@ -268,7 +455,21 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
"in": "path",
"required": true,
"schema": map[string]string{"type": "string"},
"description": "IMDB ID фильма",
"description": "IMDB ID фильма или сериала",
},
{
"name": "type",
"in": "query",
"required": true,
"schema": map[string]interface{}{"type": "string", "enum": []string{"movie", "tv", "serial"}},
"description": "Тип контента: movie (фильм) или tv/serial (сериал)",
},
{
"name": "season",
"in": "query",
"required": false,
"schema": map[string]interface{}{"type": "integer"},
"description": "Номер сезона (для сериалов)",
},
},
"responses": map[string]interface{}{
@@ -545,6 +746,36 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
},
},
},
"delete": map[string]interface{}{
"summary": "Удалить аккаунт пользователя",
"description": "Полное и безвозвратное удаление аккаунта пользователя и всех связанных с ним данных (избранное, реакции)",
"tags": []string{"Authentication"},
"security": []map[string][]string{
{"bearerAuth": []string{}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Аккаунт успешно удален",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"success": map[string]interface{}{"type": "boolean"},
"message": map[string]interface{}{"type": "string"},
},
},
},
},
},
"401": map[string]interface{}{
"description": "Неавторизованный запрос",
},
"500": map[string]interface{}{
"description": "Внутренняя ошибка сервера",
},
},
},
},
"/api/v1/movies/search": map[string]interface{}{
"get": map[string]interface{}{
@@ -651,15 +882,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
},
"/api/v1/favorites": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Получить избранные фильмы",
"description": "Список избранных фильмов пользователя",
"summary": "Получить избранное",
"description": "Список избранных фильмов и сериалов пользователя",
"tags": []string{"Favorites"},
"security": []map[string][]string{
{"bearerAuth": []string{}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Список избранных фильмов",
"description": "Список избранного",
},
},
},
@@ -667,7 +898,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
"/api/v1/favorites/{id}": map[string]interface{}{
"post": map[string]interface{}{
"summary": "Добавить в избранное",
"description": "Добавление фильма в избранное",
"description": "Добавление фильма или сериала в избранное",
"tags": []string{"Favorites"},
"security": []map[string][]string{
{"bearerAuth": []string{}},
@@ -678,18 +909,29 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
"in": "path",
"required": true,
"schema": map[string]string{"type": "string"},
"description": "ID фильма",
"description": "ID медиа",
},
{
"name": "type",
"in": "query",
"required": false,
"schema": map[string]interface{}{
"type": "string",
"enum": []string{"movie", "tv"},
"default": "movie",
},
"description": "Тип медиа: movie или tv",
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Фильм добавлен в избранное",
"description": "Добавлено в избранное",
},
},
},
"delete": map[string]interface{}{
"summary": "Удалить из избранного",
"description": "Удаление фильма из избранного",
"description": "Удаление фильма или сериала из избранного",
"tags": []string{"Favorites"},
"security": []map[string][]string{
{"bearerAuth": []string{}},
@@ -700,12 +942,58 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
"in": "path",
"required": true,
"schema": map[string]string{"type": "string"},
"description": "ID фильма",
"description": "ID медиа",
},
{
"name": "type",
"in": "query",
"required": false,
"schema": map[string]interface{}{
"type": "string",
"enum": []string{"movie", "tv"},
"default": "movie",
},
"description": "Тип медиа: movie или tv",
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Фильм удален из избранного",
"description": "Удалено из избранного",
},
},
},
},
"/api/v1/favorites/{id}/check": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Проверить избранное",
"description": "Проверка, находится ли медиа в избранном",
"tags": []string{"Favorites"},
"security": []map[string][]string{
{"bearerAuth": []string{}},
},
"parameters": []map[string]interface{}{
{
"name": "id",
"in": "path",
"required": true,
"schema": map[string]string{"type": "string"},
"description": "ID медиа",
},
{
"name": "type",
"in": "query",
"required": false,
"schema": map[string]interface{}{
"type": "string",
"enum": []string{"movie", "tv"},
"default": "movie",
},
"description": "Тип медиа: movie или tv",
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Статус избранного",
},
},
},
@@ -861,11 +1149,13 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
"name": "page",
"in": "query",
"schema": map[string]string{"type": "integer", "default": "1"},
"description": "Номер страницы",
},
{
"name": "language",
"in": "query",
"schema": map[string]string{"type": "string", "default": "ru-RU"},
"description": "Язык ответа",
},
},
"responses": map[string]interface{}{
@@ -1129,6 +1419,39 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
},
},
},
"/api/v1/auth/google/login": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Google OAuth: начало",
"description": "Редирект на страницу авторизации Google",
"tags": []string{"Authentication"},
"responses": map[string]interface{}{
"302": map[string]interface{}{"description": "Redirect to Google"},
"400": map[string]interface{}{"description": "OAuth не сконфигурирован"},
},
},
},
"/api/v1/auth/google/callback": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Google OAuth: коллбек",
"description": "Обработка кода авторизации и выдача JWT",
"tags": []string{"Authentication"},
"parameters": []map[string]interface{}{
{"name": "state", "in": "query", "required": true, "schema": map[string]string{"type": "string"}},
{"name": "code", "in": "query", "required": true, "schema": map[string]string{"type": "string"}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Успешная авторизация через Google",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/AuthResponse"},
},
},
},
"400": map[string]interface{}{"description": "Неверный state или ошибка обмена кода"},
},
},
},
},
Components: Components{
SecuritySchemes: map[string]SecurityScheme{
@@ -1326,6 +1649,71 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
"twitter_id": map[string]string{"type": "string"},
},
},
"WebTorrentMetadata": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]string{"type": "integer"},
"title": map[string]string{"type": "string"},
"type": map[string]interface{}{
"type": "string",
"enum": []string{"movie", "tv"},
},
"year": map[string]string{"type": "integer"},
"posterPath": map[string]string{"type": "string"},
"backdropPath": map[string]string{"type": "string"},
"overview": map[string]string{"type": "string"},
"runtime": map[string]string{"type": "integer"},
"genres": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"$ref": "#/components/schemas/Genre",
},
},
"seasons": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"$ref": "#/components/schemas/SeasonMetadata",
},
},
"episodes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"$ref": "#/components/schemas/EpisodeMetadata",
},
},
},
},
"SeasonMetadata": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"seasonNumber": map[string]string{"type": "integer"},
"name": map[string]string{"type": "string"},
"episodes": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"$ref": "#/components/schemas/EpisodeMetadata",
},
},
},
},
"EpisodeMetadata": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"episodeNumber": map[string]string{"type": "integer"},
"seasonNumber": map[string]string{"type": "integer"},
"name": map[string]string{"type": "string"},
"overview": map[string]string{"type": "string"},
"runtime": map[string]string{"type": "integer"},
"stillPath": map[string]string{"type": "string"},
},
},
"Genre": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]string{"type": "integer"},
"name": map[string]string{"type": "string"},
},
},
},
},
}

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

@@ -0,0 +1,260 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/gorilla/mux"
"neomovies-api/pkg/config"
"neomovies-api/pkg/middleware"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type FavoritesHandler struct {
favoritesService *services.FavoritesService
config *config.Config
}
func NewFavoritesHandler(favoritesService *services.FavoritesService, cfg *config.Config) *FavoritesHandler {
return &FavoritesHandler{
favoritesService: favoritesService,
config: cfg,
}
}
func (h *FavoritesHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusUnauthorized)
return
}
favorites, err := h.favoritesService.GetFavorites(userID)
if err != nil {
http.Error(w, "Failed to get favorites: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: favorites,
Message: "Favorites retrieved successfully",
})
}
func (h *FavoritesHandler) AddToFavorites(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
mediaID := vars["id"]
mediaType := r.URL.Query().Get("type")
if mediaID == "" {
http.Error(w, "Media ID is required", http.StatusBadRequest)
return
}
if mediaType == "" {
mediaType = "movie" // По умолчанию фильм для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
// Получаем информацию о медиа на русском языке
mediaInfo, err := h.fetchMediaInfoRussian(mediaID, mediaType)
if err != nil {
http.Error(w, "Failed to fetch media information: "+err.Error(), http.StatusInternalServerError)
return
}
err = h.favoritesService.AddToFavoritesWithInfo(userID, mediaID, mediaType, mediaInfo)
if err != nil {
http.Error(w, "Failed to add to favorites: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Added to favorites successfully",
})
}
func (h *FavoritesHandler) RemoveFromFavorites(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
mediaID := vars["id"]
mediaType := r.URL.Query().Get("type")
if mediaID == "" {
http.Error(w, "Media ID is required", http.StatusBadRequest)
return
}
if mediaType == "" {
mediaType = "movie" // По умолчанию фильм для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
err := h.favoritesService.RemoveFromFavorites(userID, mediaID, mediaType)
if err != nil {
http.Error(w, "Failed to remove from favorites: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Removed from favorites successfully",
})
}
func (h *FavoritesHandler) CheckIsFavorite(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
mediaID := vars["id"]
mediaType := r.URL.Query().Get("type")
if mediaID == "" {
http.Error(w, "Media ID is required", http.StatusBadRequest)
return
}
if mediaType == "" {
mediaType = "movie" // По умолчанию фильм для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
isFavorite, err := h.favoritesService.IsFavorite(userID, mediaID, mediaType)
if err != nil {
http.Error(w, "Failed to check favorite status: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: map[string]bool{"isFavorite": isFavorite},
})
}
// fetchMediaInfoRussian получает информацию о медиа на русском языке из TMDB
func (h *FavoritesHandler) fetchMediaInfoRussian(mediaID, mediaType string) (*models.MediaInfo, error) {
var url string
if mediaType == "movie" {
url = fmt.Sprintf("https://api.themoviedb.org/3/movie/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken)
} else {
url = fmt.Sprintf("https://api.themoviedb.org/3/tv/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken)
}
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch from TMDB: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TMDB API error: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var tmdbResponse map[string]interface{}
if err := json.Unmarshal(body, &tmdbResponse); err != nil {
return nil, fmt.Errorf("failed to parse TMDB response: %w", err)
}
mediaInfo := &models.MediaInfo{
ID: mediaID,
MediaType: mediaType,
}
// Заполняем информацию в зависимости от типа медиа
if mediaType == "movie" {
if title, ok := tmdbResponse["title"].(string); ok {
mediaInfo.Title = title
}
if originalTitle, ok := tmdbResponse["original_title"].(string); ok {
mediaInfo.OriginalTitle = originalTitle
}
if releaseDate, ok := tmdbResponse["release_date"].(string); ok {
mediaInfo.ReleaseDate = releaseDate
}
} else {
if name, ok := tmdbResponse["name"].(string); ok {
mediaInfo.Title = name
}
if originalName, ok := tmdbResponse["original_name"].(string); ok {
mediaInfo.OriginalTitle = originalName
}
if firstAirDate, ok := tmdbResponse["first_air_date"].(string); ok {
mediaInfo.FirstAirDate = firstAirDate
}
}
// Общие поля
if overview, ok := tmdbResponse["overview"].(string); ok {
mediaInfo.Overview = overview
}
if posterPath, ok := tmdbResponse["poster_path"].(string); ok {
mediaInfo.PosterPath = posterPath
}
if backdropPath, ok := tmdbResponse["backdrop_path"].(string); ok {
mediaInfo.BackdropPath = backdropPath
}
if voteAverage, ok := tmdbResponse["vote_average"].(float64); ok {
mediaInfo.VoteAverage = voteAverage
}
if voteCount, ok := tmdbResponse["vote_count"].(float64); ok {
mediaInfo.VoteCount = int(voteCount)
}
if popularity, ok := tmdbResponse["popularity"].(float64); ok {
mediaInfo.Popularity = popularity
}
// Жанры
if genres, ok := tmdbResponse["genres"].([]interface{}); ok {
for _, genre := range genres {
if genreMap, ok := genre.(map[string]interface{}); ok {
if genreID, ok := genreMap["id"].(float64); ok {
mediaInfo.GenreIDs = append(mediaInfo.GenreIDs, int(genreID))
}
}
}
}
return mediaInfo, nil
}

View File

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

View File

@@ -7,7 +7,6 @@ import (
"github.com/gorilla/mux"
"neomovies-api/pkg/middleware"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
@@ -190,74 +189,6 @@ func (h *MovieHandler) GetSimilar(w http.ResponseWriter, r *http.Request) {
})
}
func (h *MovieHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
language := r.URL.Query().Get("language")
movies, err := h.movieService.GetFavorites(userID, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
})
}
func (h *MovieHandler) AddToFavorites(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
movieID := vars["id"]
err := h.movieService.AddToFavorites(userID, movieID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Movie added to favorites",
})
}
func (h *MovieHandler) RemoveFromFavorites(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
movieID := vars["id"]
err := h.movieService.RemoveFromFavorites(userID, movieID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Movie removed from favorites",
})
}
func (h *MovieHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])

View File

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

View File

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

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

@@ -0,0 +1,24 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Favorite struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
UserID string `json:"userId" bson:"userId"`
MediaID string `json:"mediaId" bson:"mediaId"`
MediaType string `json:"mediaType" bson:"mediaType"` // "movie" or "tv"
Title string `json:"title" bson:"title"`
PosterPath string `json:"posterPath" bson:"posterPath"`
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
}
type FavoriteRequest struct {
MediaID string `json:"mediaId" validate:"required"`
MediaType string `json:"mediaType" validate:"required,oneof=movie tv"`
Title string `json:"title" validate:"required"`
PosterPath string `json:"posterPath"`
}

View File

@@ -1,5 +1,22 @@
package models
// MediaInfo represents media information structure used by handlers and services
type MediaInfo struct {
ID string `json:"id"`
Title string `json:"title"`
OriginalTitle string `json:"original_title,omitempty"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date,omitempty"`
FirstAirDate string `json:"first_air_date,omitempty"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
MediaType string `json:"media_type"`
Popularity float64 `json:"popularity"`
GenreIDs []int `json:"genre_ids"`
}
type Movie struct {
ID int `json:"id"`
Title string `json:"title"`
@@ -159,6 +176,31 @@ type Season struct {
SeasonNumber int `json:"season_number"`
}
type SeasonDetails struct {
AirDate string `json:"air_date"`
Episodes []Episode `json:"episodes"`
Name string `json:"name"`
Overview string `json:"overview"`
ID int `json:"id"`
PosterPath string `json:"poster_path"`
SeasonNumber int `json:"season_number"`
}
type Episode struct {
AirDate string `json:"air_date"`
EpisodeNumber int `json:"episode_number"`
ID int `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
ProductionCode string `json:"production_code"`
Runtime int `json:"runtime"`
SeasonNumber int `json:"season_number"`
ShowID int `json:"show_id"`
StillPath string `json:"still_path"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
}
type TMDBResponse struct {
Page int `json:"page"`
Results []Movie `json:"results"`

View File

@@ -20,6 +20,9 @@ type User struct {
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"`
Provider string `json:"provider,omitempty" bson:"provider,omitempty"`
GoogleID string `json:"googleId,omitempty" bson:"googleId,omitempty"`
RefreshTokens []RefreshToken `json:"-" bson:"refreshTokens,omitempty"`
}
type LoginRequest struct {
@@ -35,6 +38,7 @@ type RegisterRequest struct {
type AuthResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
User User `json:"user"`
}
@@ -46,3 +50,20 @@ type VerifyEmailRequest struct {
type ResendCodeRequest struct {
Email string `json:"email" validate:"required,email"`
}
type RefreshToken struct {
Token string `json:"token" bson:"token"`
ExpiresAt time.Time `json:"expiresAt" bson:"expiresAt"`
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
UserAgent string `json:"userAgent,omitempty" bson:"userAgent,omitempty"`
IPAddress string `json:"ipAddress,omitempty" bson:"ipAddress,omitempty"`
}
type TokenPair struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
type RefreshTokenRequest struct {
RefreshToken string `json:"refreshToken" validate:"required"`
}

View File

@@ -4,146 +4,227 @@ import (
"context"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"sync"
"time"
"encoding/json"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"neomovies-api/pkg/models"
)
// AuthService contains the database connection, JWT secret, and email service.
type AuthService struct {
db *mongo.Database
jwtSecret string
emailService *EmailService
baseURL string
googleClientID string
googleClientSecret string
googleRedirectURL string
frontendURL string
}
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService) *AuthService {
// Reaction represents a reaction entry in the database.
type Reaction struct {
MediaID string `bson:"mediaId"`
Type string `bson:"type"`
UserID primitive.ObjectID `bson:"userId"`
}
// NewAuthService creates and initializes a new AuthService.
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService, baseURL string, googleClientID string, googleClientSecret string, googleRedirectURL string, frontendURL string) *AuthService {
service := &AuthService{
db: db,
jwtSecret: jwtSecret,
emailService: emailService,
baseURL: baseURL,
googleClientID: googleClientID,
googleClientSecret: googleClientSecret,
googleRedirectURL: googleRedirectURL,
frontendURL: frontendURL,
}
// Запускаем тест подключения к базе данных
go service.testDatabaseConnection()
return service
}
// testDatabaseConnection тестирует подключение к базе данных и выводит информацию о пользователях
func (s *AuthService) testDatabaseConnection() {
ctx := context.Background()
fmt.Println("=== DATABASE CONNECTION TEST ===")
// Проверяем подключение
err := s.db.Client().Ping(ctx, nil)
if err != nil {
fmt.Printf("❌ Database connection failed: %v\n", err)
return
func (s *AuthService) googleOAuthConfig() *oauth2.Config {
redirectURL := s.googleRedirectURL
if redirectURL == "" && s.baseURL != "" {
redirectURL = fmt.Sprintf("%s/api/v1/auth/google/callback", s.baseURL)
}
return &oauth2.Config{
ClientID: s.googleClientID,
ClientSecret: s.googleClientSecret,
RedirectURL: redirectURL,
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
}
}
fmt.Printf("✅ Database connection successful\n")
fmt.Printf("📊 Database name: %s\n", s.db.Name())
// Получаем список всех коллекций
collections, err := s.db.ListCollectionNames(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to list collections: %v\n", err)
return
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
}
fmt.Printf("📁 Available collections: %v\n", collections)
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")
}
// Проверяем коллекцию 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
// 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)
}
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
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,
}
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
if _, err := collection.InsertOne(ctx, user); err != nil {
return nil, err
}
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 if err != nil {
return nil, err
} else {
fmt.Printf(" ✅ User %s: FOUND (Name: %s, Verified: %v)\n",
email,
user["name"],
user["verified"])
// 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})
}
fmt.Println("=== END DATABASE TEST ===")
// 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
}
// Генерация 6-значного кода
return &models.AuthResponse{
Token: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
User: user,
}, nil
}
// generateVerificationCode creates a 6-digit verification code.
func (s *AuthService) generateVerificationCode() string {
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
}
// Register registers a new user.
func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
// Проверяем, не существует ли уже пользователь с таким email
var existingUser models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser)
if err == nil {
return nil, errors.New("email already registered")
}
// Хешируем пароль
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Генерируем код верификации
code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute) // 10 минут
codeExpires := time.Now().Add(10 * time.Minute)
// Создаем нового пользователя (НЕ ВЕРИФИЦИРОВАННОГО)
user := models.User{
ID: primitive.NewObjectID(),
Email: req.Email,
@@ -164,7 +245,6 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface
return nil, err
}
// Отправляем код верификации на email
if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code)
}
@@ -175,44 +255,43 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface
}, nil
}
func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) {
// Login authenticates a user.
func (s *AuthService) LoginWithTokens(req models.LoginRequest, userAgent, ipAddress string) (*models.AuthResponse, error) {
collection := s.db.Collection("users")
fmt.Printf("🔍 Login attempt for email: %s\n", req.Email)
fmt.Printf("📊 Database name: %s\n", s.db.Name())
fmt.Printf("📁 Collection name: %s\n", collection.Name())
// Находим пользователя по email (точно как в JavaScript)
var user models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
if err != nil {
fmt.Printf("❌ User not found: %v\n", err)
return nil, errors.New("User not found")
}
// Проверяем верификацию email (точно как в JavaScript)
if !user.Verified {
return nil, errors.New("Account not activated. Please verify your email.")
}
// Проверяем пароль (точно как в JavaScript)
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
if err != nil {
return nil, errors.New("Invalid password")
}
// Генерируем JWT токен
token, err := s.generateJWT(user.ID.Hex())
tokenPair, err := s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress)
if err != nil {
return nil, err
}
return &models.AuthResponse{
Token: token,
Token: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
User: user,
}, nil
}
// Login authenticates a user (legacy method for backward compatibility).
func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) {
return s.LoginWithTokens(req, "", "")
}
// GetUserByID retrieves a user by their ID.
func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
collection := s.db.Collection("users")
@@ -230,6 +309,7 @@ func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
return &user, nil
}
// UpdateUser updates a user's information.
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
collection := s.db.Collection("users")
@@ -252,10 +332,11 @@ func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, e
return s.GetUserByID(userID)
}
// generateJWT generates a new JWT for a given user ID.
func (s *AuthService) generateJWT(userID string) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 дней
"exp": time.Now().Add(time.Hour * 1).Unix(), // Сократил время жизни до 1 часа
"iat": time.Now().Unix(),
"jti": uuid.New().String(),
}
@@ -264,7 +345,159 @@ func (s *AuthService) generateJWT(userID string) (string, error) {
return token.SignedString([]byte(s.jwtSecret))
}
// Верификация email
// generateRefreshToken generates a new refresh token
func (s *AuthService) generateRefreshToken() string {
return uuid.New().String()
}
// generateTokenPair generates both access and refresh tokens
func (s *AuthService) generateTokenPair(userID, userAgent, ipAddress string) (*models.TokenPair, error) {
accessToken, err := s.generateJWT(userID)
if err != nil {
return nil, err
}
refreshToken := s.generateRefreshToken()
// Сохраняем refresh token в базе данных
collection := s.db.Collection("users")
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return nil, err
}
refreshTokenDoc := models.RefreshToken{
Token: refreshToken,
ExpiresAt: time.Now().Add(time.Hour * 24 * 30), // 30 дней
CreatedAt: time.Now(),
UserAgent: userAgent,
IPAddress: ipAddress,
}
// Удаляем старые истекшие токены и добавляем новый
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": objectID},
bson.M{
"$pull": bson.M{
"refreshTokens": bson.M{
"expiresAt": bson.M{"$lt": time.Now()},
},
},
},
)
if err != nil {
return nil, err
}
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": objectID},
bson.M{
"$push": bson.M{
"refreshTokens": refreshTokenDoc,
},
"$set": bson.M{
"updatedAt": time.Now(),
},
},
)
if err != nil {
return nil, err
}
return &models.TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
}, nil
}
// RefreshAccessToken refreshes an access token using a refresh token
func (s *AuthService) RefreshAccessToken(refreshToken, userAgent, ipAddress string) (*models.TokenPair, error) {
collection := s.db.Collection("users")
// Найти пользователя с данным refresh токеном
var user models.User
err := collection.FindOne(
context.Background(),
bson.M{
"refreshTokens": bson.M{
"$elemMatch": bson.M{
"token": refreshToken,
"expiresAt": bson.M{"$gt": time.Now()},
},
},
},
).Decode(&user)
if err != nil {
return nil, errors.New("invalid or expired refresh token")
}
// Удалить использованный refresh token
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": user.ID},
bson.M{
"$pull": bson.M{
"refreshTokens": bson.M{
"token": refreshToken,
},
},
},
)
if err != nil {
return nil, err
}
// Создать новую пару токенов
return s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress)
}
// RevokeRefreshToken revokes a specific refresh token
func (s *AuthService) RevokeRefreshToken(userID, refreshToken string) error {
collection := s.db.Collection("users")
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return err
}
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": objectID},
bson.M{
"$pull": bson.M{
"refreshTokens": bson.M{
"token": refreshToken,
},
},
},
)
return err
}
// RevokeAllRefreshTokens revokes all refresh tokens for a user
func (s *AuthService) RevokeAllRefreshTokens(userID string) error {
collection := s.db.Collection("users")
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return err
}
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": objectID},
bson.M{
"$set": bson.M{
"refreshTokens": []models.RefreshToken{},
"updatedAt": time.Now(),
},
},
)
return err
}
// VerifyEmail verifies a user's email with a code.
func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
@@ -281,12 +514,10 @@ func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]int
}, nil
}
// Проверяем код и срок действия
if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) {
return nil, errors.New("invalid or expired verification code")
}
// Верифицируем пользователя
_, err = collection.UpdateOne(
context.Background(),
bson.M{"email": req.Email},
@@ -308,7 +539,7 @@ func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]int
}, nil
}
// Повторная отправка кода верификации
// ResendVerificationCode sends a new verification email.
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
@@ -322,11 +553,9 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
return nil, errors.New("email already verified")
}
// Генерируем новый код
code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute)
// Обновляем код в базе
_, err = collection.UpdateOne(
context.Background(),
bson.M{"email": req.Email},
@@ -341,7 +570,6 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
return nil, err
}
// Отправляем новый код на email
if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code)
}
@@ -351,3 +579,76 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
"message": "Verification code sent to your email",
}, nil
}
// DeleteAccount deletes a user and all associated data.
func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error {
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return fmt.Errorf("invalid user ID format: %w", err)
}
// Step 1: Find user reactions and remove them from cub.rip
if s.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
}

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

@@ -0,0 +1,184 @@
package services
import (
"context"
"fmt"
"strconv"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models"
)
type FavoritesService struct {
db *mongo.Database
tmdb *TMDBService
}
func NewFavoritesService(db *mongo.Database, tmdb *TMDBService) *FavoritesService {
return &FavoritesService{
db: db,
tmdb: tmdb,
}
}
func (s *FavoritesService) AddToFavorites(userID, mediaID, mediaType string) error {
collection := s.db.Collection("favorites")
// Проверяем, не добавлен ли уже в избранное
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
var existingFavorite models.Favorite
err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite)
if err == nil {
// Уже в избранном
return nil
}
var title, posterPath string
// Получаем информацию из TMDB в зависимости от типа медиа
mediaIDInt, err := strconv.Atoi(mediaID)
if err != nil {
return fmt.Errorf("invalid media ID: %s", mediaID)
}
if mediaType == "movie" {
movie, err := s.tmdb.GetMovie(mediaIDInt, "en-US")
if err != nil {
return err
}
title = movie.Title
posterPath = movie.PosterPath
} else if mediaType == "tv" {
tv, err := s.tmdb.GetTVShow(mediaIDInt, "en-US")
if err != nil {
return err
}
title = tv.Name
posterPath = tv.PosterPath
} else {
return fmt.Errorf("invalid media type: %s", mediaType)
}
// Формируем полный URL для постера
if posterPath != "" {
posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath)
}
favorite := models.Favorite{
UserID: userID,
MediaID: mediaID,
MediaType: mediaType,
Title: title,
PosterPath: posterPath,
CreatedAt: time.Now(),
}
_, err = collection.InsertOne(context.Background(), favorite)
return err
}
// AddToFavoritesWithInfo adds media to favorites with provided media information
func (s *FavoritesService) AddToFavoritesWithInfo(userID, mediaID, mediaType string, mediaInfo *models.MediaInfo) error {
collection := s.db.Collection("favorites")
// Проверяем, не добавлен ли уже в избранное
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
var existingFavorite models.Favorite
err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite)
if err == nil {
// Уже в избранном
return nil
}
// Формируем полный URL для постера если он есть
posterPath := mediaInfo.PosterPath
if posterPath != "" && posterPath[0] == '/' {
posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath)
}
favorite := models.Favorite{
UserID: userID,
MediaID: mediaID,
MediaType: mediaType,
Title: mediaInfo.Title,
PosterPath: posterPath,
CreatedAt: time.Now(),
}
_, err = collection.InsertOne(context.Background(), favorite)
return err
}
func (s *FavoritesService) RemoveFromFavorites(userID, mediaID, mediaType string) error {
collection := s.db.Collection("favorites")
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
_, err := collection.DeleteOne(context.Background(), filter)
return err
}
func (s *FavoritesService) GetFavorites(userID string) ([]models.Favorite, error) {
collection := s.db.Collection("favorites")
filter := bson.M{
"userId": userID,
}
cursor, err := collection.Find(context.Background(), filter)
if err != nil {
return nil, err
}
defer cursor.Close(context.Background())
var favorites []models.Favorite
err = cursor.All(context.Background(), &favorites)
if err != nil {
return nil, err
}
// Возвращаем пустой массив вместо nil если нет избранных
if favorites == nil {
favorites = []models.Favorite{}
}
return favorites, nil
}
func (s *FavoritesService) IsFavorite(userID, mediaID, mediaType string) (bool, error) {
collection := s.db.Collection("favorites")
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
var favorite models.Favorite
err := collection.FindOne(context.Background(), filter).Decode(&favorite)
if err != nil {
if err == mongo.ErrNoDocuments {
return false, nil
}
return false, err
}
return true, nil
}

View File

@@ -1,23 +1,17 @@
package services
import (
"context"
"strconv"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models"
)
type MovieService struct {
db *mongo.Database
tmdb *TMDBService
}
func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService {
return &MovieService{
db: db,
tmdb: tmdb,
}
}
@@ -54,57 +48,6 @@ func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBRe
return s.tmdb.GetSimilarMovies(id, page, language)
}
func (s *MovieService) AddToFavorites(userID string, movieID string) error {
collection := s.db.Collection("users")
filter := bson.M{"_id": userID}
update := bson.M{
"$addToSet": bson.M{"favorites": movieID},
}
_, err := collection.UpdateOne(context.Background(), filter, update)
return err
}
func (s *MovieService) RemoveFromFavorites(userID string, movieID string) error {
collection := s.db.Collection("users")
filter := bson.M{"_id": userID}
update := bson.M{
"$pull": bson.M{"favorites": movieID},
}
_, err := collection.UpdateOne(context.Background(), filter, update)
return err
}
func (s *MovieService) GetFavorites(userID string, language string) ([]models.Movie, error) {
collection := s.db.Collection("users")
var user models.User
err := collection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&user)
if err != nil {
return nil, err
}
var movies []models.Movie
for _, movieIDStr := range user.Favorites {
movieID, err := strconv.Atoi(movieIDStr)
if err != nil {
continue
}
movie, err := s.tmdb.GetMovie(movieID, language)
if err != nil {
continue
}
movies = append(movies, *movie)
}
return movies, nil
}
func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
return s.tmdb.GetMovieExternalIDs(id)
}

View File

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

View File

@@ -119,6 +119,11 @@ func (s *TMDBService) SearchMulti(query string, page int, language string) (*mod
return &response, nil
}
// Алиас для совместимости с новым WebTorrent handler
func (s *TMDBService) SearchTV(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
return s.SearchTVShows(query, page, language, firstAirDateYear)
}
func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("query", query)
@@ -477,3 +482,34 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string)
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) DiscoverTVByGenre(genreID, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
params.Set("with_genres", strconv.Itoa(genreID))
params.Set("sort_by", "popularity.desc")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/discover/tv?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetTVSeason(tvID, seasonNumber int, language string) (*models.SeasonDetails, error) {
if language == "" {
language = "ru-RU"
}
endpoint := fmt.Sprintf("%s/tv/%d/season/%d?language=%s", s.baseURL, tvID, seasonNumber, language)
var season models.SeasonDetails
err := s.makeRequest(endpoint, &season)
return &season, err
}

View File

@@ -21,11 +21,19 @@ type TorrentService struct {
apiKey string
}
func NewTorrentServiceWithConfig(baseURL, apiKey string) *TorrentService {
return &TorrentService{
client: &http.Client{Timeout: 8 * time.Second},
baseURL: baseURL,
apiKey: apiKey,
}
}
func NewTorrentService() *TorrentService {
return &TorrentService{
client: &http.Client{Timeout: 8 * time.Second},
baseURL: "http://redapi.cfhttp.top",
apiKey: "", // Может быть установлен через переменные окружения
apiKey: "",
}
}
@@ -33,7 +41,6 @@ func NewTorrentService() *TorrentService {
func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) {
searchParams := url.Values{}
// Добавляем все параметры поиска
for key, value := range params {
if value != "" {
if key == "category" {
@@ -80,7 +87,6 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
var results []models.TorrentResult
for _, torrent := range data.Results {
// Обрабатываем размер - может быть строкой или числом
var sizeStr string
switch v := torrent.Size.(type) {
case string:
@@ -106,9 +112,7 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
Source: "RedAPI",
}
// Добавляем информацию из Info если она есть
if torrent.Info != nil {
// Обрабатываем качество - может быть строкой или числом
switch v := torrent.Info.Quality.(type) {
case string:
result.Quality = v
@@ -123,7 +127,6 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
result.Seasons = torrent.Info.Seasons
}
// Если качество не определено через Info, пытаемся извлечь из названия
if result.Quality == "" {
result.Quality = s.ExtractQuality(result.Title)
}
@@ -136,52 +139,69 @@ func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) {
// Получаем информацию о фильме/сериале из TMDB
title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType)
if err != nil {
return nil, fmt.Errorf("failed to get title from TMDB: %w", err)
}
// Формируем параметры поиска
params := make(map[string]string)
params["imdb"] = imdbID
params["title"] = title
params["title_original"] = originalTitle
params["year"] = year
params := map[string]string{
"imdb": imdbID,
"query": title,
"title_original": originalTitle,
"year": year,
}
// Устанавливаем тип контента и категорию
switch mediaType {
case "movie":
params["is_serial"] = "1"
params["category"] = "2000"
case "tv", "series":
case "serial", "series", "tv":
params["is_serial"] = "2"
params["category"] = "5000"
case "anime":
params["is_serial"] = "5"
params["category"] = "5070"
default:
params["is_serial"] = "1"
params["category"] = "2000"
}
// Добавляем сезон если указан
if options != nil && options.Season != nil {
if options != nil && options.Season != nil && *options.Season > 0 {
params["season"] = strconv.Itoa(*options.Season)
}
// Выполняем поиск
response, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
// Применяем фильтрацию
if options != nil {
response.Results = s.FilterByContentType(response.Results, options.ContentType)
response.Results = s.FilterTorrents(response.Results, options)
response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder)
}
response.Total = len(response.Results)
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
@@ -444,14 +464,12 @@ func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, medi
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
}
// FilterByContentType - фильтрация по типу контента
// FilterByContentType - фильтрация по типу контента (как в JS)
func (s *TorrentService) FilterByContentType(results []models.TorrentResult, contentType string) []models.TorrentResult {
if contentType == "" {
return results
}
var filtered []models.TorrentResult
for _, torrent := range results {
// Фильтрация по полю types, если оно есть
if len(torrent.Types) > 0 {
@@ -460,7 +478,7 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
if s.containsAny(torrent.Types, []string{"movie", "multfilm", "documovie"}) {
filtered = append(filtered, torrent)
}
case "serial":
case "serial", "series", "tv":
if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) {
filtered = append(filtered, torrent)
}
@@ -471,7 +489,6 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
}
continue
}
// Фильтрация по названию, если types недоступно
title := strings.ToLower(torrent.Title)
switch contentType {
@@ -479,7 +496,7 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
filtered = append(filtered, torrent)
}
case "serial":
case "serial", "series", "tv":
if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
filtered = append(filtered, torrent)
}
@@ -491,7 +508,6 @@ func (s *TorrentService) FilterByContentType(results []models.TorrentResult, con
filtered = append(filtered, torrent)
}
}
return filtered
}
@@ -644,7 +660,9 @@ func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, s
case "size":
less = s.compareSizes(torrents[i].Size, torrents[j].Size)
case "date":
less = torrents[i].PublishDate < torrents[j].PublishDate
t1, _ := time.Parse(time.RFC3339, torrents[i].PublishDate)
t2, _ := time.Parse(time.RFC3339, torrents[j].PublishDate)
less = t1.Before(t2)
default:
less = torrents[i].Seeders < torrents[j].Seeders
}
@@ -785,14 +803,88 @@ func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string)
return seasons, nil
}
// Вспомогательные функции
// SearchByImdb - поиск по IMDB ID (movie/serial/anime).
func (s *TorrentService) SearchByImdb(imdbID, contentType string, season *int) ([]models.TorrentResult, error) {
if imdbID == "" || !strings.HasPrefix(imdbID, "tt") {
return nil, fmt.Errorf("Неверный формат IMDB ID. Должен быть в формате tt1234567")
}
// НЕ добавляем title, originalTitle, year, чтобы запрос не был слишком строгим.
params := map[string]string{
"imdb": imdbID,
}
// Определяем тип контента для API
switch contentType {
case "movie":
params["is_serial"] = "1"
params["category"] = "2000"
case "serial", "series", "tv":
params["is_serial"] = "2"
params["category"] = "5000"
case "anime":
params["is_serial"] = "5"
params["category"] = "5070"
default:
// Значение по умолчанию на случай неизвестного типа
params["is_serial"] = "1"
params["category"] = "2000"
}
// Параметр season можно оставить, он полезен
if season != nil && *season > 0 {
params["season"] = strconv.Itoa(*season)
}
resp, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
results := resp.Results
// Fallback для сериалов: если указан сезон и результатов мало, ищем без сезона и фильтруем на клиенте
if s.contains([]string{"serial", "series", "tv"}, contentType) && season != nil && len(results) < 5 {
paramsNoSeason := map[string]string{
"imdb": imdbID,
"is_serial": "2",
"category": "5000",
}
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
if err == nil {
filtered := s.filterBySeason(fallbackResp.Results, *season)
// Объединяем и убираем дубликаты по MagnetLink
all := append(results, filtered...)
unique := make([]models.TorrentResult, 0, len(all))
seen := make(map[string]bool)
for _, t := range all {
if !seen[t.MagnetLink] {
unique = append(unique, t)
seen[t.MagnetLink] = true
}
}
results = unique
}
}
// Финальная фильтрация по типу контента на стороне клиента для надежности
results = s.FilterByContentType(results, contentType)
return results, nil
}
// ############# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ #############
func (s *TorrentService) qualityMeetsMinimum(quality, minQuality string) bool {
qualityOrder := map[string]int{
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
}
currentLevel := qualityOrder[strings.ToLower(quality)]
minLevel := qualityOrder[strings.ToLower(minQuality)]
currentLevel, ok1 := qualityOrder[strings.ToLower(quality)]
minLevel, ok2 := qualityOrder[strings.ToLower(minQuality)]
if !ok1 || !ok2 {
return true // Если качество не определено, не фильтруем
}
return currentLevel >= minLevel
}
@@ -802,20 +894,31 @@ func (s *TorrentService) qualityMeetsMaximum(quality, maxQuality string) bool {
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
}
currentLevel := qualityOrder[strings.ToLower(quality)]
maxLevel := qualityOrder[strings.ToLower(maxQuality)]
currentLevel, ok1 := qualityOrder[strings.ToLower(quality)]
maxLevel, ok2 := qualityOrder[strings.ToLower(maxQuality)]
if !ok1 || !ok2 {
return true // Если качество не определено, не фильтруем
}
return currentLevel <= maxLevel
}
func (s *TorrentService) parseSize(sizeStr string) int64 {
val, err := strconv.ParseInt(sizeStr, 10, 64)
if err != nil {
return 0
}
return val
}
func (s *TorrentService) compareSizes(size1, size2 string) bool {
// Простое сравнение размеров (можно улучшить)
return len(size1) < len(size2)
return s.parseSize(size1) < s.parseSize(size2)
}
func (s *TorrentService) contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
if strings.EqualFold(s, item) {
return true
}
}