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

110
README.md
View File

@@ -1,48 +1,16 @@
# Neo Movies API (Go Version) 🎬
# Neo Movies API
> Современный API для поиска фильмов и сериалов, портированный с Node.js на Go
REST API для поиска и получения информации о фильмах, использующий TMDB API.
## 🚀 Особенности
## Особенности
- **Высокая производительность** - написан на Go
- 🔒 **JWT аутентификация** с email верификацией
- 🎭 **TMDB API интеграция** для данных о фильмах/сериалах
- 📧 **Email уведомления** через Gmail SMTP
- 🔍 **Полнотекстовый поиск** фильмов и сериалов
- **Система избранного** для пользователей
- 🎨 **Современная документация** с 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,10 +148,10 @@ POST /api/v1/favorites/{id} # Добавить в избран
DELETE /api/v1/favorites/{id} # Удалить из избранного
# Реакции (приватные)
GET /api/v1/reactions/{type}/{id}/my-reaction # Моя реакция
POST /api/v1/reactions/{type}/{id} # Установить реакцию
DELETE /api/v1/reactions/{type}/{id} # Удалить реакцию
GET /api/v1/reactions/my # Все мои реакции
GET /api/v1/reactions/{mediaType}/{mediaId}/my-reaction # Моя реакция
POST /api/v1/reactions/{mediaType}/{mediaId} # Установить реакцию
DELETE /api/v1/reactions/{mediaType}/{mediaId} # Удалить реакцию
GET /api/v1/reactions/my # Все мои реакции
```
## 📖 Примеры использования

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=

97
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/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
api.HandleFunc("/players/vibix/{imdb_id}", playersHandler.GetVibixPlayer).Methods("GET")
// Торренты
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET")
@@ -89,13 +86,10 @@ func main() {
api.HandleFunc("/torrents/seasons", torrentsHandler.GetAvailableSeasons).Methods("GET")
api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET")
// Реакции (публичные)
api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET")
// Изображения (прокси для TMDB)
api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET")
// Маршруты для фильмов (некоторые публичные, некоторые приватные)
api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET")
api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET")
api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET")
@@ -104,8 +98,8 @@ func main() {
api.HandleFunc("/movies/{id}", movieHandler.GetByID).Methods("GET")
api.HandleFunc("/movies/{id}/recommendations", movieHandler.GetRecommendations).Methods("GET")
api.HandleFunc("/movies/{id}/similar", movieHandler.GetSimilar).Methods("GET")
api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET")
// Маршруты для сериалов
api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET")
api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET")
api.HandleFunc("/tv/top-rated", tvHandler.TopRated).Methods("GET")
@@ -114,42 +108,59 @@ func main() {
api.HandleFunc("/tv/{id}", tvHandler.GetByID).Methods("GET")
api.HandleFunc("/tv/{id}/recommendations", tvHandler.GetRecommendations).Methods("GET")
api.HandleFunc("/tv/{id}/similar", tvHandler.GetSimilar).Methods("GET")
api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET")
// Приватные маршруты (требуют авторизации)
protected := api.PathPrefix("").Subrouter()
protected.Use(middleware.JWTAuth(cfg.JWTSecret))
// Избранное
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET")
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST")
protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE")
protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET")
protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST")
protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE")
protected.HandleFunc("/favorites/{id}/check", favoritesHandler.CheckIsFavorite).Methods("GET")
// Пользовательские данные
protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET")
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE")
protected.HandleFunc("/auth/revoke-token", authHandler.RevokeRefreshToken).Methods("POST")
protected.HandleFunc("/auth/revoke-all-tokens", authHandler.RevokeAllRefreshTokens).Methods("POST")
// Реакции (приватные)
protected.HandleFunc("/reactions/{mediaType}/{mediaId}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET")
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST")
protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE")
protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET")
// CORS и другие middleware
corsHandler := handlers.CORS(
handlers.AllowedOrigins([]string{"*"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}),
handlers.AllowCredentials(),
handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With", "X-CSRF-Token"}),
handlers.AllowCredentials(),
handlers.ExposedHeaders([]string{"Authorization", "Content-Type"}),
)
// Определяем порт
port := os.Getenv("PORT")
var finalHandler http.Handler
if cfg.NodeEnv == "development" {
r.Use(monitor.RequestMonitor())
finalHandler = corsHandler(r)
fmt.Println("\n🚀 NeoMovies API Server")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Printf("📡 Server: http://localhost:%s\n", cfg.Port)
fmt.Printf("📚 Docs: http://localhost:%s/\n", cfg.Port)
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Printf("%-6s %-3s │ %-60s │ %8s\n", "METHOD", "CODE", "ENDPOINT", "TIME")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
} else {
finalHandler = corsHandler(r)
fmt.Printf("✅ Server starting on port %s\n", cfg.Port)
}
port := cfg.Port
if port == "" {
port = "3000"
}
log.Printf("Server starting on port %s", port)
log.Printf("API documentation available at: http://localhost:%s/", port)
log.Fatal(http.ListenAndServe(":"+port, corsHandler(r)))
if err := http.ListenAndServe(":"+port, finalHandler); err != nil {
fmt.Printf("❌ Server failed to start: %v\n", err)
os.Exit(1)
}
}

View File

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

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 {
@@ -156,4 +226,84 @@ func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Requ
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
}
// 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"
}
// Используем discover API для получения фильмов по жанру
movies, err := h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
mediaType := r.URL.Query().Get("type")
if mediaType == "" {
mediaType = "movie" // По умолчанию фильмы для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
var data interface{}
var err2 error
if mediaType == "movie" {
// Используем discover API для получения фильмов по жанру
data, err2 = h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
} else {
// Используем discover API для получения сериалов по жанру
data, err2 = h.tmdbService.DiscoverTVByGenre(categoryID, page, language)
}
if err2 != nil {
http.Error(w, err2.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
Data: data,
Message: "Media retrieved successfully",
})
}
// Старый метод для обратной совместимости
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
// Просто перенаправляем на новый метод
h.GetMediaByCategory(w, r)
}
func generateSlug(name string) string {
// Простая функция для создания slug из названия
// В реальном проекте стоит использовать более сложную логику
@@ -93,4 +119,4 @@ func generateSlug(name string) string {
}
}
return result
}
}

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

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"])
@@ -284,11 +215,11 @@ func getIntQuery(r *http.Request, key string, defaultValue int) int {
if str == "" {
return defaultValue
}
value, err := strconv.Atoi(str)
if err != nil {
return defaultValue
}
return value
}
}

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -7,19 +7,22 @@ import (
)
type User struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
Email string `json:"email" bson:"email" validate:"required,email"`
Password string `json:"-" bson:"password" validate:"required,min=6"`
Name string `json:"name" bson:"name" validate:"required"`
Avatar string `json:"avatar" bson:"avatar"`
Favorites []string `json:"favorites" bson:"favorites"`
Verified bool `json:"verified" bson:"verified"`
VerificationCode string `json:"-" bson:"verificationCode,omitempty"`
VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"`
IsAdmin bool `json:"isAdmin" bson:"isAdmin"`
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"`
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
Email string `json:"email" bson:"email" validate:"required,email"`
Password string `json:"-" bson:"password" validate:"required,min=6"`
Name string `json:"name" bson:"name" validate:"required"`
Avatar string `json:"avatar" bson:"avatar"`
Favorites []string `json:"favorites" bson:"favorites"`
Verified bool `json:"verified" bson:"verified"`
VerificationCode string `json:"-" bson:"verificationCode,omitempty"`
VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"`
IsAdmin bool `json:"isAdmin" bson:"isAdmin"`
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"`
Provider string `json:"provider,omitempty" bson:"provider,omitempty"`
GoogleID string `json:"googleId,omitempty" bson:"googleId,omitempty"`
RefreshTokens []RefreshToken `json:"-" bson:"refreshTokens,omitempty"`
}
type LoginRequest struct {
@@ -34,8 +37,9 @@ type RegisterRequest struct {
}
type AuthResponse struct {
Token string `json:"token"`
User User `json:"user"`
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
User User `json:"user"`
}
type VerifyEmailRequest struct {
@@ -45,4 +49,21 @@ type VerifyEmailRequest struct {
type ResendCodeRequest struct {
Email string `json:"email" validate:"required,email"`
}
}
type RefreshToken struct {
Token string `json:"token" bson:"token"`
ExpiresAt time.Time `json:"expiresAt" bson:"expiresAt"`
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
UserAgent string `json:"userAgent,omitempty" bson:"userAgent,omitempty"`
IPAddress string `json:"ipAddress,omitempty" bson:"ipAddress,omitempty"`
}
type TokenPair struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
type RefreshTokenRequest struct {
RefreshToken string `json:"refreshToken" validate:"required"`
}

View File

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

View File

@@ -4,159 +4,240 @@ import (
"context"
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"sync"
"time"
"encoding/json"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"neomovies-api/pkg/models"
)
// AuthService contains the database connection, JWT secret, and email service.
type AuthService struct {
db *mongo.Database
jwtSecret string
emailService *EmailService
db *mongo.Database
jwtSecret string
emailService *EmailService
baseURL string
googleClientID string
googleClientSecret string
googleRedirectURL string
frontendURL string
}
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService) *AuthService {
// Reaction represents a reaction entry in the database.
type Reaction struct {
MediaID string `bson:"mediaId"`
Type string `bson:"type"`
UserID primitive.ObjectID `bson:"userId"`
}
// NewAuthService creates and initializes a new AuthService.
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService, baseURL string, googleClientID string, googleClientSecret string, googleRedirectURL string, frontendURL string) *AuthService {
service := &AuthService{
db: db,
jwtSecret: jwtSecret,
emailService: emailService,
db: db,
jwtSecret: jwtSecret,
emailService: emailService,
baseURL: baseURL,
googleClientID: googleClientID,
googleClientSecret: googleClientSecret,
googleRedirectURL: googleRedirectURL,
frontendURL: frontendURL,
}
// Запускаем тест подключения к базе данных
go service.testDatabaseConnection()
return service
}
// testDatabaseConnection тестирует подключение к базе данных и выводит информацию о пользователях
func (s *AuthService) testDatabaseConnection() {
ctx := context.Background()
fmt.Println("=== DATABASE CONNECTION TEST ===")
// Проверяем подключение
err := s.db.Client().Ping(ctx, nil)
if err != nil {
fmt.Printf("❌ Database connection failed: %v\n", err)
return
func (s *AuthService) googleOAuthConfig() *oauth2.Config {
redirectURL := s.googleRedirectURL
if redirectURL == "" && s.baseURL != "" {
redirectURL = fmt.Sprintf("%s/api/v1/auth/google/callback", s.baseURL)
}
fmt.Printf("✅ Database connection successful\n")
fmt.Printf("📊 Database name: %s\n", s.db.Name())
// Получаем список всех коллекций
collections, err := s.db.ListCollectionNames(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to list collections: %v\n", err)
return
return &oauth2.Config{
ClientID: s.googleClientID,
ClientSecret: s.googleClientSecret,
RedirectURL: redirectURL,
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
}
fmt.Printf("📁 Available collections: %v\n", collections)
// Проверяем коллекцию users
collection := s.db.Collection("users")
// Подсчитываем количество документов
count, err := collection.CountDocuments(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to count users: %v\n", err)
return
}
fmt.Printf("👥 Total users in database: %d\n", count)
if count > 0 {
// Показываем всех пользователей
cursor, err := collection.Find(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to find users: %v\n", err)
return
}
defer cursor.Close(ctx)
var users []bson.M
if err := cursor.All(ctx, &users); err != nil {
fmt.Printf("❌ Failed to decode users: %v\n", err)
return
}
fmt.Printf("📋 All users in database:\n")
for i, user := range users {
fmt.Printf(" %d. Email: %s, Name: %s, Verified: %v\n",
i+1,
user["email"],
user["name"],
user["verified"])
}
// Тестируем поиск конкретного пользователя
fmt.Printf("\n🔍 Testing specific user search:\n")
testEmails := []string{"neo.movies.mail@gmail.com", "fenixoffc@gmail.com", "test@example.com"}
for _, email := range testEmails {
var user bson.M
err := collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
if err != nil {
fmt.Printf(" ❌ User %s: NOT FOUND (%v)\n", email, err)
} else {
fmt.Printf(" ✅ User %s: FOUND (Name: %s, Verified: %v)\n",
email,
user["name"],
user["verified"])
}
}
}
fmt.Println("=== END DATABASE TEST ===")
}
// Генерация 6-значного кода
func (s *AuthService) GetGoogleLoginURL(state string) (string, error) {
cfg := s.googleOAuthConfig()
if cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" {
return "", errors.New("google oauth not configured")
}
return cfg.AuthCodeURL(state, oauth2.AccessTypeOffline), nil
}
type googleUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
EmailVerified bool `json:"email_verified"`
}
// BuildFrontendRedirect builds frontend URL for redirect after OAuth; returns false if not configured
func (s *AuthService) BuildFrontendRedirect(token string, authErr string) (string, bool) {
if s.frontendURL == "" {
return "", false
}
if authErr != "" {
u, _ := url.Parse(s.frontendURL + "/login")
q := u.Query()
q.Set("oauth", "google")
q.Set("error", authErr)
u.RawQuery = q.Encode()
return u.String(), true
}
u, _ := url.Parse(s.frontendURL + "/auth/callback")
q := u.Query()
q.Set("provider", "google")
q.Set("token", token)
u.RawQuery = q.Encode()
return u.String(), true
}
func (s *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*models.AuthResponse, error) {
cfg := s.googleOAuthConfig()
tok, err := cfg.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
client := cfg.Client(ctx, tok)
resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
if err != nil {
return nil, fmt.Errorf("failed to fetch userinfo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("userinfo request failed: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var gUser googleUserInfo
if err := json.Unmarshal(body, &gUser); err != nil {
return nil, fmt.Errorf("failed to parse userinfo: %w", err)
}
if gUser.Email == "" {
return nil, errors.New("email not provided by Google")
}
collection := s.db.Collection("users")
// Try by googleId first
var user models.User
err = collection.FindOne(ctx, bson.M{"googleId": gUser.Sub}).Decode(&user)
if err == mongo.ErrNoDocuments {
// Try by email
err = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user)
}
if err == mongo.ErrNoDocuments {
// Create new user
user = models.User{
ID: primitive.NewObjectID(),
Email: gUser.Email,
Password: "",
Name: gUser.Name,
Avatar: gUser.Picture,
Favorites: []string{},
Verified: true,
IsAdmin: false,
AdminVerified: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Provider: "google",
GoogleID: gUser.Sub,
}
if _, err := collection.InsertOne(ctx, user); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
} else {
// Existing user: ensure fields
update := bson.M{
"verified": true,
"provider": "google",
"googleId": gUser.Sub,
"updatedAt": time.Now(),
}
if user.Name == "" && gUser.Name != "" {
update["name"] = gUser.Name
}
if user.Avatar == "" && gUser.Picture != "" {
update["avatar"] = gUser.Picture
}
_, _ = collection.UpdateOne(ctx, bson.M{"_id": user.ID}, bson.M{"$set": update})
}
// Generate JWT
if user.ID.IsZero() {
// If we created user above, we already have user.ID set; else fetch updated
_ = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user)
}
tokenPair, err := s.generateTokenPair(user.ID.Hex(), "", "")
if err != nil {
return nil, err
}
return &models.AuthResponse{
Token: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
User: user,
}, nil
}
// generateVerificationCode creates a 6-digit verification code.
func (s *AuthService) generateVerificationCode() string {
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
}
// Register registers a new user.
func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
// Проверяем, не существует ли уже пользователь с таким email
var existingUser models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser)
if err == nil {
return nil, errors.New("email already registered")
}
// Хешируем пароль
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Генерируем код верификации
code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute) // 10 минут
codeExpires := time.Now().Add(10 * time.Minute)
// Создаем нового пользователя (НЕ ВЕРИФИЦИРОВАННОГО)
user := models.User{
ID: primitive.NewObjectID(),
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Favorites: []string{},
Verified: false,
VerificationCode: code,
ID: primitive.NewObjectID(),
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Favorites: []string{},
Verified: false,
VerificationCode: code,
VerificationExpires: codeExpires,
IsAdmin: false,
AdminVerified: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
IsAdmin: false,
AdminVerified: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = collection.InsertOne(context.Background(), user)
@@ -164,7 +245,6 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface
return nil, err
}
// Отправляем код верификации на email
if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code)
}
@@ -175,44 +255,43 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface
}, nil
}
func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) {
// Login authenticates a user.
func (s *AuthService) LoginWithTokens(req models.LoginRequest, userAgent, ipAddress string) (*models.AuthResponse, error) {
collection := s.db.Collection("users")
fmt.Printf("🔍 Login attempt for email: %s\n", req.Email)
fmt.Printf("📊 Database name: %s\n", s.db.Name())
fmt.Printf("📁 Collection name: %s\n", collection.Name())
// Находим пользователя по email (точно как в JavaScript)
var user models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
if err != nil {
fmt.Printf("❌ User not found: %v\n", err)
return nil, errors.New("User not found")
}
// Проверяем верификацию email (точно как в JavaScript)
if !user.Verified {
return nil, errors.New("Account not activated. Please verify your email.")
}
// Проверяем пароль (точно как в JavaScript)
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
if err != nil {
return nil, errors.New("Invalid password")
}
// Генерируем JWT токен
token, err := s.generateJWT(user.ID.Hex())
tokenPair, err := s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress)
if err != nil {
return nil, err
}
return &models.AuthResponse{
Token: token,
User: user,
Token: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken,
User: user,
}, nil
}
// Login authenticates a user (legacy method for backward compatibility).
func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) {
return s.LoginWithTokens(req, "", "")
}
// GetUserByID retrieves a user by their ID.
func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
collection := s.db.Collection("users")
@@ -230,6 +309,7 @@ func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
return &user, nil
}
// UpdateUser updates a user's information.
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
collection := s.db.Collection("users")
@@ -252,10 +332,11 @@ func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, e
return s.GetUserByID(userID)
}
// generateJWT generates a new JWT for a given user ID.
func (s *AuthService) generateJWT(userID string) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 дней
"exp": time.Now().Add(time.Hour * 1).Unix(), // Сократил время жизни до 1 часа
"iat": time.Now().Unix(),
"jti": uuid.New().String(),
}
@@ -264,7 +345,159 @@ func (s *AuthService) generateJWT(userID string) (string, error) {
return token.SignedString([]byte(s.jwtSecret))
}
// Верификация email
// generateRefreshToken generates a new refresh token
func (s *AuthService) generateRefreshToken() string {
return uuid.New().String()
}
// generateTokenPair generates both access and refresh tokens
func (s *AuthService) generateTokenPair(userID, userAgent, ipAddress string) (*models.TokenPair, error) {
accessToken, err := s.generateJWT(userID)
if err != nil {
return nil, err
}
refreshToken := s.generateRefreshToken()
// Сохраняем refresh token в базе данных
collection := s.db.Collection("users")
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return nil, err
}
refreshTokenDoc := models.RefreshToken{
Token: refreshToken,
ExpiresAt: time.Now().Add(time.Hour * 24 * 30), // 30 дней
CreatedAt: time.Now(),
UserAgent: userAgent,
IPAddress: ipAddress,
}
// Удаляем старые истекшие токены и добавляем новый
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": objectID},
bson.M{
"$pull": bson.M{
"refreshTokens": bson.M{
"expiresAt": bson.M{"$lt": time.Now()},
},
},
},
)
if err != nil {
return nil, err
}
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": objectID},
bson.M{
"$push": bson.M{
"refreshTokens": refreshTokenDoc,
},
"$set": bson.M{
"updatedAt": time.Now(),
},
},
)
if err != nil {
return nil, err
}
return &models.TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
}, nil
}
// RefreshAccessToken refreshes an access token using a refresh token
func (s *AuthService) RefreshAccessToken(refreshToken, userAgent, ipAddress string) (*models.TokenPair, error) {
collection := s.db.Collection("users")
// Найти пользователя с данным refresh токеном
var user models.User
err := collection.FindOne(
context.Background(),
bson.M{
"refreshTokens": bson.M{
"$elemMatch": bson.M{
"token": refreshToken,
"expiresAt": bson.M{"$gt": time.Now()},
},
},
},
).Decode(&user)
if err != nil {
return nil, errors.New("invalid or expired refresh token")
}
// Удалить использованный refresh token
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": user.ID},
bson.M{
"$pull": bson.M{
"refreshTokens": bson.M{
"token": refreshToken,
},
},
},
)
if err != nil {
return nil, err
}
// Создать новую пару токенов
return s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress)
}
// RevokeRefreshToken revokes a specific refresh token
func (s *AuthService) RevokeRefreshToken(userID, refreshToken string) error {
collection := s.db.Collection("users")
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return err
}
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": objectID},
bson.M{
"$pull": bson.M{
"refreshTokens": bson.M{
"token": refreshToken,
},
},
},
)
return err
}
// RevokeAllRefreshTokens revokes all refresh tokens for a user
func (s *AuthService) RevokeAllRefreshTokens(userID string) error {
collection := s.db.Collection("users")
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return err
}
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": objectID},
bson.M{
"$set": bson.M{
"refreshTokens": []models.RefreshToken{},
"updatedAt": time.Now(),
},
},
)
return err
}
// VerifyEmail verifies a user's email with a code.
func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
@@ -281,12 +514,10 @@ func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]int
}, nil
}
// Проверяем код и срок действия
if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) {
return nil, errors.New("invalid or expired verification code")
}
// Верифицируем пользователя
_, err = collection.UpdateOne(
context.Background(),
bson.M{"email": req.Email},
@@ -308,7 +539,7 @@ func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]int
}, nil
}
// Повторная отправка кода верификации
// ResendVerificationCode sends a new verification email.
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
@@ -322,11 +553,9 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
return nil, errors.New("email already verified")
}
// Генерируем новый код
code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute)
// Обновляем код в базе
_, err = collection.UpdateOne(
context.Background(),
bson.M{"email": req.Email},
@@ -341,7 +570,6 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
return nil, err
}
// Отправляем новый код на email
if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code)
}
@@ -350,4 +578,77 @@ func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[
"success": true,
"message": "Verification code sent to your email",
}, nil
}
}
// DeleteAccount deletes a user and all associated data.
func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error {
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return fmt.Errorf("invalid user ID format: %w", err)
}
// Step 1: Find user reactions and remove them from cub.rip
if s.baseURL != "" { // Changed from cubAPIURL to baseURL
reactionsCollection := s.db.Collection("reactions")
var userReactions []Reaction
cursor, err := reactionsCollection.Find(ctx, bson.M{"userId": objectID})
if err != nil {
return fmt.Errorf("failed to find user reactions: %w", err)
}
if err = cursor.All(ctx, &userReactions); err != nil {
return fmt.Errorf("failed to decode user reactions: %w", err)
}
var wg sync.WaitGroup
client := &http.Client{Timeout: 10 * time.Second}
for _, reaction := range userReactions {
wg.Add(1)
go func(r Reaction) {
defer wg.Done()
url := fmt.Sprintf("%s/reactions/remove/%s/%s", s.baseURL, r.MediaID, r.Type) // Changed from cubAPIURL to baseURL
req, err := http.NewRequestWithContext(ctx, "POST", url, nil) // or "DELETE"
if err != nil {
// Log the error but don't stop the process
fmt.Printf("failed to create request for cub.rip: %v\n", err)
return
}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("failed to send request to cub.rip: %v\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
fmt.Printf("cub.rip API responded with status %d: %s\n", resp.StatusCode, body)
}
}(reaction)
}
wg.Wait()
}
// Step 2: Delete all user-related data from the database
usersCollection := s.db.Collection("users")
favoritesCollection := s.db.Collection("favorites")
reactionsCollection := s.db.Collection("reactions")
_, err = usersCollection.DeleteOne(ctx, bson.M{"_id": objectID})
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
_, err = favoritesCollection.DeleteMany(ctx, bson.M{"userId": objectID})
if err != nil {
return fmt.Errorf("failed to delete user favorites: %w", err)
}
_, err = reactionsCollection.DeleteMany(ctx, bson.M{"userId": objectID})
if err != nil {
return fmt.Errorf("failed to delete user reactions: %w", err)
}
return nil
}

View File

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

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

View File

@@ -52,23 +52,23 @@ func (s *TMDBService) SearchMovies(query string, page int, language, region stri
params.Set("query", query)
params.Set("page", strconv.Itoa(page))
params.Set("include_adult", "false")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
if year > 0 {
params.Set("year", strconv.Itoa(year))
}
endpoint := fmt.Sprintf("%s/search/movie?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -119,24 +119,29 @@ func (s *TMDBService) SearchMulti(query string, page int, language string) (*mod
return &response, nil
}
// Алиас для совместимости с новым WebTorrent handler
func (s *TMDBService) SearchTV(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
return s.SearchTVShows(query, page, language, firstAirDateYear)
}
func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("query", query)
params.Set("page", strconv.Itoa(page))
params.Set("include_adult", "false")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if firstAirDateYear > 0 {
params.Set("first_air_date_year", strconv.Itoa(firstAirDateYear))
}
endpoint := fmt.Sprintf("%s/search/tv?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -144,7 +149,7 @@ func (s *TMDBService) SearchTVShows(query string, page int, language string, fir
func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) {
params := url.Values{}
if language != "" {
params.Set("language", language)
} else {
@@ -152,7 +157,7 @@ func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) {
}
endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode())
var movie models.Movie
err := s.makeRequest(endpoint, &movie)
return &movie, err
@@ -160,7 +165,7 @@ func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) {
func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) {
params := url.Values{}
if language != "" {
params.Set("language", language)
} else {
@@ -168,7 +173,7 @@ func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error)
}
endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode())
var tvShow models.TVShow
err := s.makeRequest(endpoint, &tvShow)
return &tvShow, err
@@ -176,7 +181,7 @@ func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error)
func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) {
params := url.Values{}
if language != "" {
params.Set("language", language)
} else {
@@ -184,7 +189,7 @@ func (s *TMDBService) GetGenres(mediaType string, language string) (*models.Genr
}
endpoint := fmt.Sprintf("%s/genre/%s/list?%s", s.baseURL, mediaType, params.Encode())
var response models.GenresResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -205,11 +210,11 @@ func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) {
// Объединяем жанры, убирая дубликаты
allGenres := make(map[int]models.Genre)
for _, genre := range movieGenres.Genres {
allGenres[genre.ID] = genre
}
for _, genre := range tvGenres.Genres {
allGenres[genre.ID] = genre
}
@@ -226,19 +231,19 @@ func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) {
func (s *TMDBService) GetPopularMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/popular?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -247,19 +252,19 @@ func (s *TMDBService) GetPopularMovies(page int, language, region string) (*mode
func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/top_rated?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -268,19 +273,19 @@ func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*mod
func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/upcoming?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -289,19 +294,19 @@ func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*mod
func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/now_playing?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -310,7 +315,7 @@ func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*m
func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
@@ -318,7 +323,7 @@ func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*m
}
endpoint := fmt.Sprintf("%s/movie/%d/recommendations?%s", s.baseURL, id, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -327,7 +332,7 @@ func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*m
func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
@@ -335,7 +340,7 @@ func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.T
}
endpoint := fmt.Sprintf("%s/movie/%d/similar?%s", s.baseURL, id, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -344,7 +349,7 @@ func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.T
func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
@@ -352,7 +357,7 @@ func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDB
}
endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -361,7 +366,7 @@ func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDB
func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
@@ -369,7 +374,7 @@ func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMD
}
endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -378,7 +383,7 @@ func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMD
func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
@@ -386,7 +391,7 @@ func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMD
}
endpoint := fmt.Sprintf("%s/tv/on_the_air?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -395,7 +400,7 @@ func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMD
func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
@@ -403,7 +408,7 @@ func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.
}
endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -412,7 +417,7 @@ func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.
func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
@@ -420,7 +425,7 @@ func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*mode
}
endpoint := fmt.Sprintf("%s/tv/%d/recommendations?%s", s.baseURL, id, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -429,7 +434,7 @@ func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*mode
func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
@@ -437,7 +442,7 @@ func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.
}
endpoint := fmt.Sprintf("%s/tv/%d/similar?%s", s.baseURL, id, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
@@ -445,7 +450,7 @@ func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.
func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) {
endpoint := fmt.Sprintf("%s/movie/%d/external_ids", s.baseURL, id)
var ids models.ExternalIDs
err := s.makeRequest(endpoint, &ids)
return &ids, err
@@ -453,7 +458,7 @@ func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) {
func (s *TMDBService) GetTVExternalIDs(id int) (*models.ExternalIDs, error) {
endpoint := fmt.Sprintf("%s/tv/%d/external_ids", s.baseURL, id)
var ids models.ExternalIDs
err := s.makeRequest(endpoint, &ids)
return &ids, err
@@ -464,7 +469,7 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string)
params.Set("page", strconv.Itoa(page))
params.Set("with_genres", strconv.Itoa(genreID))
params.Set("sort_by", "popularity.desc")
if language != "" {
params.Set("language", language)
} else {
@@ -472,8 +477,39 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string)
}
endpoint := fmt.Sprintf("%s/discover/movie?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
}
func (s *TMDBService) DiscoverTVByGenre(genreID, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
params.Set("with_genres", strconv.Itoa(genreID))
params.Set("sort_by", "popularity.desc")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/discover/tv?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetTVSeason(tvID, seasonNumber int, language string) (*models.SeasonDetails, error) {
if language == "" {
language = "ru-RU"
}
endpoint := fmt.Sprintf("%s/tv/%d/season/%d?language=%s", s.baseURL, tvID, seasonNumber, language)
var season models.SeasonDetails
err := s.makeRequest(endpoint, &season)
return &season, err
}

View File

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

View File

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