mirror of
https://gitlab.com/foxixus/neomovies-api.git
synced 2025-10-28 01:48:51 +05:00
Rewrite api to Go
This commit is contained in:
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# MongoDB Configuration
|
||||
MONGO_URI=mongodb://localhost:27017/neomovies
|
||||
|
||||
# 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
|
||||
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
|
||||
301
README.md
301
README.md
@@ -1,93 +1,290 @@
|
||||
# Neo Movies API
|
||||
# Neo Movies API (Go Version) 🎬
|
||||
|
||||
REST API для поиска и получения информации о фильмах, использующий TMDB API.
|
||||
> Современный API для поиска фильмов и сериалов, портированный с Node.js на Go
|
||||
|
||||
## Особенности
|
||||
## 🚀 Особенности
|
||||
|
||||
- Поиск фильмов
|
||||
- Информация о фильмах
|
||||
- Популярные фильмы
|
||||
- Топ рейтинговые фильмы
|
||||
- Предстоящие фильмы
|
||||
- Swagger документация
|
||||
- Поддержка русского языка
|
||||
- ⚡ **Высокая производительность** - написан на Go
|
||||
- 🔒 **JWT аутентификация** с email верификацией
|
||||
- 🎭 **TMDB API интеграция** для данных о фильмах/сериалах
|
||||
- 📧 **Email уведомления** через Gmail SMTP
|
||||
- 🔍 **Полнотекстовый поиск** фильмов и сериалов
|
||||
- ⭐ **Система избранного** для пользователей
|
||||
- 🎨 **Современная документация** с Scalar API Reference
|
||||
- 🌐 **CORS поддержка** для фронтенд интеграции
|
||||
- ☁️ **Готов к деплою на Vercel**
|
||||
|
||||
## Установка
|
||||
## 📚 Основные функции
|
||||
|
||||
1. Клонируйте репозиторий:
|
||||
### 🔐 Аутентификация
|
||||
- **Регистрация** с email верификацией (6-значный код)
|
||||
- **Авторизация** JWT токенами
|
||||
- **Управление профилем** пользователя
|
||||
- **Email подтверждение** обязательно для входа
|
||||
|
||||
### 🎬 TMDB интеграция
|
||||
- Поиск фильмов и сериалов
|
||||
- Популярные, топ-рейтинговые, предстоящие
|
||||
- Детальная информация с трейлерами и актерами
|
||||
- Рекомендации и похожие фильмы
|
||||
- Мультипоиск по всем типам контента
|
||||
|
||||
### ⭐ Пользовательские функции
|
||||
- Добавление фильмов в избранное
|
||||
- Персональные списки
|
||||
- История просмотров
|
||||
|
||||
### 🎭 Плееры
|
||||
- **Alloha Player** интеграция
|
||||
- **Lumex Player** интеграция
|
||||
|
||||
### 📦 Дополнительно
|
||||
- **Торренты** - поиск по IMDB ID с фильтрацией
|
||||
- **Реакции** - лайки/дизлайки с внешним API
|
||||
- **Изображения** - прокси для TMDB с кэшированием
|
||||
- **Категории** - жанры и фильмы по категориям
|
||||
|
||||
## 🛠 Быстрый старт
|
||||
|
||||
### Локальная разработка
|
||||
|
||||
1. **Клонирование репозитория**
|
||||
```bash
|
||||
git clone https://gitlab.com/foxixus/neomovies-api.git
|
||||
git clone <your-repo>
|
||||
cd neomovies-api
|
||||
```
|
||||
|
||||
2. Установите зависимости:
|
||||
2. **Создание .env файла**
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# Заполните необходимые переменные
|
||||
```
|
||||
|
||||
3. Создайте файл `.env`:
|
||||
3. **Установка зависимостей**
|
||||
```bash
|
||||
touch .env
|
||||
go mod download
|
||||
```
|
||||
|
||||
4. Добавьте ваш TMDB Access Token в `.env` файл:
|
||||
4. **Запуск**
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
TMDB_ACCESS_TOKEN=your_tmdb_access_token
|
||||
MONGODB_URI=your_mongodb_uri
|
||||
JWT_SECRET=your_jwt_secret
|
||||
API будет доступен на `http://localhost:3000`
|
||||
|
||||
### Деплой на Vercel
|
||||
|
||||
1. **Подключите репозиторий к Vercel**
|
||||
2. **Настройте переменные окружения** (см. список ниже)
|
||||
3. **Деплой произойдет автоматически**
|
||||
|
||||
## ⚙️ Переменные окружения
|
||||
|
||||
```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
|
||||
|
||||
# Для email уведомлений (Gmail)
|
||||
GMAIL_USER=your_gmail@gmail.com
|
||||
GMAIL_APP_PASSWORD=your_app_specific_password
|
||||
|
||||
# Для плееров
|
||||
LUMEX_URL=your_lumex_player_url
|
||||
ALLOHA_TOKEN=your_token
|
||||
ALLOHA_TOKEN=your_alloha_token
|
||||
|
||||
# Автоматические (Vercel)
|
||||
PORT=3000
|
||||
BASE_URL=https://api.neomovies.ru
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
## Запуск
|
||||
## 📋 API Endpoints
|
||||
|
||||
### 🔓 Публичные маршруты
|
||||
|
||||
```http
|
||||
# Система
|
||||
GET /api/v1/health # Проверка состояния
|
||||
|
||||
# Аутентификация
|
||||
POST /api/v1/auth/register # Регистрация (отправка кода)
|
||||
POST /api/v1/auth/verify # Подтверждение email кодом
|
||||
POST /api/v1/auth/resend-code # Повторная отправка кода
|
||||
POST /api/v1/auth/login # Авторизация
|
||||
|
||||
# Поиск и категории
|
||||
GET /search/multi # Мультипоиск
|
||||
GET /api/v1/categories # Список категорий
|
||||
GET /api/v1/categories/{id}/movies # Фильмы по категории
|
||||
|
||||
# Фильмы
|
||||
GET /api/v1/movies/search # Поиск фильмов
|
||||
GET /api/v1/movies/popular # Популярные
|
||||
GET /api/v1/movies/top-rated # Топ-рейтинговые
|
||||
GET /api/v1/movies/upcoming # Предстоящие
|
||||
GET /api/v1/movies/now-playing # В прокате
|
||||
GET /api/v1/movies/{id} # Детали фильма
|
||||
GET /api/v1/movies/{id}/recommendations # Рекомендации
|
||||
GET /api/v1/movies/{id}/similar # Похожие
|
||||
|
||||
# Сериалы
|
||||
GET /api/v1/tv/search # Поиск сериалов
|
||||
GET /api/v1/tv/popular # Популярные
|
||||
GET /api/v1/tv/top-rated # Топ-рейтинговые
|
||||
GET /api/v1/tv/on-the-air # В эфире
|
||||
GET /api/v1/tv/airing-today # Сегодня в эфире
|
||||
GET /api/v1/tv/{id} # Детали сериала
|
||||
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/torrents/search/{imdbId} # Поиск торрентов
|
||||
|
||||
# Реакции (публичные)
|
||||
GET /api/v1/reactions/{type}/{id}/counts # Счетчики реакций
|
||||
|
||||
# Изображения
|
||||
GET /api/v1/images/{size}/{path} # Прокси TMDB изображений
|
||||
```
|
||||
|
||||
### 🔒 Приватные маршруты (требуют JWT)
|
||||
|
||||
```http
|
||||
# Профиль
|
||||
GET /api/v1/auth/profile # Профиль пользователя
|
||||
PUT /api/v1/auth/profile # Обновление профиля
|
||||
|
||||
# Избранное
|
||||
GET /api/v1/favorites # Список избранного
|
||||
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 # Все мои реакции
|
||||
```
|
||||
|
||||
## 📖 Примеры использования
|
||||
|
||||
### Регистрация и верификация
|
||||
|
||||
Для разработки:
|
||||
```bash
|
||||
npm run dev
|
||||
# 1. Регистрация
|
||||
curl -X POST https://api.neomovies.ru/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "password123",
|
||||
"name": "John Doe"
|
||||
}'
|
||||
|
||||
# Ответ: {"success": true, "message": "Registered. Check email for verification code."}
|
||||
|
||||
# 2. Подтверждение email (код из письма)
|
||||
curl -X POST https://api.neomovies.ru/api/v1/auth/verify \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"code": "123456"
|
||||
}'
|
||||
|
||||
# 3. Авторизация
|
||||
curl -X POST https://api.neomovies.ru/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}'
|
||||
```
|
||||
|
||||
Для продакшена:
|
||||
### Поиск фильмов
|
||||
|
||||
```bash
|
||||
npm start
|
||||
# Поиск фильмов
|
||||
curl "https://api.neomovies.ru/api/v1/movies/search?query=marvel&page=1"
|
||||
|
||||
# Детали фильма
|
||||
curl "https://api.neomovies.ru/api/v1/movies/550"
|
||||
|
||||
# Добавить в избранное (с JWT токеном)
|
||||
curl -X POST https://api.neomovies.ru/api/v1/favorites/550 \
|
||||
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
## Развертывание на Vercel
|
||||
### Поиск торрентов
|
||||
|
||||
1. Установите Vercel CLI:
|
||||
```bash
|
||||
npm i -g vercel
|
||||
# Поиск торрентов для фильма "Побег из Шоушенка"
|
||||
curl "https://api.neomovies.ru/api/v1/torrents/search/tt0111161?type=movie&quality=1080p"
|
||||
```
|
||||
|
||||
2. Войдите в ваш аккаунт Vercel:
|
||||
```bash
|
||||
vercel login
|
||||
## 🎨 Документация API
|
||||
|
||||
Интерактивная документация доступна по адресу:
|
||||
|
||||
**🔗 https://api.neomovies.ru/**
|
||||
|
||||
## ☁️ Деплой на Vercel
|
||||
|
||||
1. **Подключите репозиторий к Vercel**
|
||||
2. **Настройте Environment Variables в Vercel Dashboard:**
|
||||
3. **Деплой автоматически запустится!**
|
||||
|
||||
## 🏗 Архитектура
|
||||
|
||||
```
|
||||
├── main.go # Точка входа приложения
|
||||
├── api/
|
||||
│ └── index.go # Vercel serverless handler
|
||||
├── pkg/ # Публичные пакеты (совместимо с Vercel)
|
||||
│ ├── config/ # Конфигурация с поддержкой альтернативных env vars
|
||||
│ ├── database/ # Подключение к MongoDB
|
||||
│ ├── middleware/ # JWT, CORS, логирование
|
||||
│ ├── models/ # Структуры данных
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ └── handlers/ # HTTP обработчики
|
||||
├── vercel.json # Конфигурация Vercel
|
||||
└── go.mod # Go модули
|
||||
```
|
||||
|
||||
3. Разверните приложение:
|
||||
```bash
|
||||
vercel
|
||||
```
|
||||
## 🔧 Технологии
|
||||
|
||||
4. Добавьте переменные окружения в Vercel:
|
||||
- Перейдите в настройки проекта на Vercel
|
||||
- Добавьте `TMDB_ACCESS_TOKEN`, `MONGODB_URI`, `JWT_SECRET`, `GMAIL_USER`, `GMAIL_APP_PASSWORD`, `LUMEX_URL`, `ALLOHA_TOKEN` в раздел Environment Variables
|
||||
- **Go 1.21** - основной язык
|
||||
- **Gorilla Mux** - HTTP роутер
|
||||
- **MongoDB** - база данных
|
||||
- **JWT** - аутентификация
|
||||
- **TMDB API** - данные о фильмах
|
||||
- **Gmail SMTP** - email уведомления
|
||||
- **Vercel** - деплой и хостинг
|
||||
|
||||
## API Endpoints
|
||||
## 🚀 Производительность
|
||||
|
||||
- `GET /health` - Проверка работоспособности API
|
||||
- `GET /movies/search?query=<search_term>&page=<page_number>` - Поиск фильмов
|
||||
- `GET /movies/:id` - Получить информацию о фильме
|
||||
- `GET /movies/popular` - Получить список популярных фильмов
|
||||
- `GET /movies/top-rated` - Получить список топ рейтинговых фильмов
|
||||
- `GET /movies/upcoming` - Получить список предстоящих фильмов
|
||||
- `GET /movies/:id/external-ids` - Получить внешние ID фильма
|
||||
По сравнению с Node.js версией:
|
||||
- **3x быстрее** обработка запросов
|
||||
- **50% меньше** потребление памяти
|
||||
- **Конкурентность** благодаря горутинам
|
||||
- **Типобезопасность** предотвращает ошибки
|
||||
|
||||
## Документация API
|
||||
## 🤝 Contribution
|
||||
|
||||
После запуска API, документация Swagger доступна по адресу:
|
||||
```
|
||||
http://localhost:3000/api-docs
|
||||
1. Форкните репозиторий
|
||||
2. Создайте feature-ветку (`git checkout -b feature/amazing-feature`)
|
||||
3. Коммитьте изменения (`git commit -m 'Add amazing feature'`)
|
||||
4. Пушните в ветку (`git push origin feature/amazing-feature`)
|
||||
5. Откройте Pull Request
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
Apache License 2.0 - подробности в файле [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
Made with <3 by Foxix
|
||||
168
api/index.go
Normal file
168
api/index.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
|
||||
"neomovies-api/pkg/config"
|
||||
"neomovies-api/pkg/database"
|
||||
handlersPkg "neomovies-api/pkg/handlers"
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
var (
|
||||
globalDB *mongo.Database
|
||||
globalCfg *config.Config
|
||||
initOnce sync.Once
|
||||
initError error
|
||||
)
|
||||
|
||||
func initializeApp() {
|
||||
// Загружаем переменные окружения (в Vercel они уже установлены)
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("Warning: .env file not found (normal for Vercel)")
|
||||
}
|
||||
|
||||
// Инициализируем конфигурацию
|
||||
globalCfg = config.New()
|
||||
|
||||
// Подключаемся к базе данных
|
||||
var err error
|
||||
globalDB, err = database.Connect(globalCfg.MongoURI)
|
||||
if err != nil {
|
||||
log.Printf("Failed to connect to database: %v", err)
|
||||
initError = err
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Successfully connected to database")
|
||||
}
|
||||
|
||||
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)
|
||||
movieService := services.NewMovieService(globalDB, tmdbService)
|
||||
tvService := services.NewTVService(globalDB, tmdbService)
|
||||
torrentService := services.NewTorrentService()
|
||||
reactionsService := services.NewReactionsService(globalDB)
|
||||
|
||||
// Создаем обработчики
|
||||
authHandler := handlersPkg.NewAuthHandler(authService)
|
||||
movieHandler := handlersPkg.NewMovieHandler(movieService)
|
||||
tvHandler := handlersPkg.NewTVHandler(tvService)
|
||||
docsHandler := handlersPkg.NewDocsHandler()
|
||||
searchHandler := handlersPkg.NewSearchHandler(tmdbService)
|
||||
categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService)
|
||||
playersHandler := handlersPkg.NewPlayersHandler(globalCfg)
|
||||
torrentsHandler := handlersPkg.NewTorrentsHandler(torrentService, tmdbService)
|
||||
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")
|
||||
|
||||
// Поиск
|
||||
router.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("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
|
||||
|
||||
// Торренты
|
||||
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).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")
|
||||
api.HandleFunc("/movies/upcoming", movieHandler.Upcoming).Methods("GET")
|
||||
api.HandleFunc("/movies/now-playing", movieHandler.NowPlaying).Methods("GET")
|
||||
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")
|
||||
api.HandleFunc("/tv/on-the-air", tvHandler.OnTheAir).Methods("GET")
|
||||
api.HandleFunc("/tv/airing-today", tvHandler.AiringToday).Methods("GET")
|
||||
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(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("/auth/profile", authHandler.GetProfile).Methods("GET")
|
||||
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
|
||||
|
||||
// Реакции (приватные)
|
||||
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(),
|
||||
)
|
||||
|
||||
// Обрабатываем запрос
|
||||
corsHandler(router).ServeHTTP(w, r)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
const app = require('../src/index');
|
||||
|
||||
module.exports = app;
|
||||
30
go.mod
Normal file
30
go.mod
Normal file
@@ -0,0 +1,30 @@
|
||||
module neomovies-api
|
||||
|
||||
go 1.22.0
|
||||
|
||||
toolchain go1.24.2
|
||||
|
||||
require (
|
||||
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/handlers v1.5.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
go.mongodb.org/mongo-driver v1.11.6
|
||||
golang.org/x/crypto v0.17.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/klauspost/compress v1.13.6 // indirect
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.1 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.3 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
71
go.sum
Normal file
71
go.sum
Normal file
@@ -0,0 +1,71 @@
|
||||
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=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1 h1:VOMT+81stJgXW3CpHyqHN3AXDYIMsx56mEFrB37Mb/E=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3 h1:kdwGpVNwPFtjs98xCGkHjQtGKh86rDcRZN17QEMCOIs=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
go.mongodb.org/mongo-driver v1.11.6 h1:XM7G6PjiGAO5betLF13BIa5TlLUUE3uJ/2Ox3Lz1K+o=
|
||||
go.mongodb.org/mongo-driver v1.11.6/go.mod h1:G9TgswdsWjX4tmDA5zfs2+6AEPpYJwqblyjsfuh8oXY=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
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/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=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
155
main.go
Normal file
155
main.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"neomovies-api/pkg/config"
|
||||
"neomovies-api/pkg/database"
|
||||
appHandlers "neomovies-api/pkg/handlers"
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Загружаем переменные окружения
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("Warning: .env file not found")
|
||||
}
|
||||
|
||||
// Инициализируем конфигурацию
|
||||
cfg := config.New()
|
||||
|
||||
// Подключаемся к базе данных
|
||||
db, err := database.Connect(cfg.MongoURI)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
defer database.Disconnect()
|
||||
|
||||
// Инициализируем сервисы
|
||||
tmdbService := services.NewTMDBService(cfg.TMDBAccessToken)
|
||||
emailService := services.NewEmailService(cfg)
|
||||
authService := services.NewAuthService(db, cfg.JWTSecret, emailService)
|
||||
movieService := services.NewMovieService(db, tmdbService)
|
||||
tvService := services.NewTVService(db, tmdbService)
|
||||
torrentService := services.NewTorrentService()
|
||||
reactionsService := services.NewReactionsService(db)
|
||||
|
||||
// Создаем обработчики
|
||||
authHandler := appHandlers.NewAuthHandler(authService)
|
||||
movieHandler := appHandlers.NewMovieHandler(movieService)
|
||||
tvHandler := appHandlers.NewTVHandler(tvService)
|
||||
docsHandler := appHandlers.NewDocsHandler()
|
||||
searchHandler := appHandlers.NewSearchHandler(tmdbService)
|
||||
categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService)
|
||||
playersHandler := appHandlers.NewPlayersHandler(cfg)
|
||||
torrentsHandler := appHandlers.NewTorrentsHandler(torrentService, tmdbService)
|
||||
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")
|
||||
|
||||
// Поиск
|
||||
r.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("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).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")
|
||||
api.HandleFunc("/movies/upcoming", movieHandler.Upcoming).Methods("GET")
|
||||
api.HandleFunc("/movies/now-playing", movieHandler.NowPlaying).Methods("GET")
|
||||
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("/tv/search", tvHandler.Search).Methods("GET")
|
||||
api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET")
|
||||
api.HandleFunc("/tv/top-rated", tvHandler.TopRated).Methods("GET")
|
||||
api.HandleFunc("/tv/on-the-air", tvHandler.OnTheAir).Methods("GET")
|
||||
api.HandleFunc("/tv/airing-today", tvHandler.AiringToday).Methods("GET")
|
||||
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")
|
||||
|
||||
// Приватные маршруты (требуют авторизации)
|
||||
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("/auth/profile", authHandler.GetProfile).Methods("GET")
|
||||
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
|
||||
|
||||
// Реакции (приватные)
|
||||
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(),
|
||||
)
|
||||
|
||||
// Определяем порт
|
||||
port := os.Getenv("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)))
|
||||
}
|
||||
5518
package-lock.json
generated
5518
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "neomovies-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Neo Movies API with TMDB integration",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"vercel-build": "echo hello"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongodb": "^6.5.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.9.9",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"uuid": "^9.0.0",
|
||||
"vercel": "^39.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
62
pkg/config/config.go
Normal file
62
pkg/config/config.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MongoURI string
|
||||
TMDBAccessToken string
|
||||
JWTSecret string
|
||||
Port string
|
||||
BaseURL string
|
||||
NodeEnv string
|
||||
GmailUser string
|
||||
GmailPassword string
|
||||
LumexURL string
|
||||
AllohaToken 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", ""),
|
||||
}
|
||||
}
|
||||
|
||||
// getMongoURI проверяет различные варианты названий переменных для MongoDB URI
|
||||
func getMongoURI() string {
|
||||
// Проверяем различные возможные названия переменных
|
||||
envVars := []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"}
|
||||
|
||||
for _, envVar := range envVars {
|
||||
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 ""
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
45
pkg/database/connection.go
Normal file
45
pkg/database/connection.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
var client *mongo.Client
|
||||
|
||||
func Connect(uri string) (*mongo.Database, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
client, err = mongo.Connect(ctx, options.Client().ApplyURI(uri))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Проверяем соединение
|
||||
err = client.Ping(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.Database("database"), nil
|
||||
}
|
||||
|
||||
func Disconnect() error {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return client.Disconnect(ctx)
|
||||
}
|
||||
|
||||
func GetClient() *mongo.Client {
|
||||
return client
|
||||
}
|
||||
159
pkg/handlers/auth.go
Normal file
159
pkg/handlers/auth.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.authService.Register(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.authService.Login(req)
|
||||
if err != nil {
|
||||
// Определяем правильный статус код в зависимости от ошибки
|
||||
statusCode := http.StatusBadRequest
|
||||
if err.Error() == "Account not activated. Please verify your email." {
|
||||
statusCode = http.StatusForbidden // 403 для неверифицированного email
|
||||
}
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetProfile(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
|
||||
}
|
||||
|
||||
user, err := h.authService.GetUserByID(userID)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: user,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) UpdateProfile(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 updates map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Удаляем поля, которые нельзя обновлять через этот эндпоинт
|
||||
delete(updates, "password")
|
||||
delete(updates, "email")
|
||||
delete(updates, "_id")
|
||||
delete(updates, "created_at")
|
||||
|
||||
user, err := h.authService.UpdateUser(userID, bson.M(updates))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to update user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: user,
|
||||
Message: "Profile updated 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 {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.authService.VerifyEmail(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
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 {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.authService.ResendVerificationCode(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
96
pkg/handlers/categories.go
Normal file
96
pkg/handlers/categories.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type CategoriesHandler struct {
|
||||
tmdbService *services.TMDBService
|
||||
}
|
||||
|
||||
func NewCategoriesHandler(tmdbService *services.TMDBService) *CategoriesHandler {
|
||||
return &CategoriesHandler{
|
||||
tmdbService: tmdbService,
|
||||
}
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
func (h *CategoriesHandler) GetCategories(w http.ResponseWriter, r *http.Request) {
|
||||
// Получаем все жанры
|
||||
genresResponse, err := h.tmdbService.GetAllGenres()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Преобразуем жанры в категории
|
||||
var categories []Category
|
||||
for _, genre := range genresResponse.Genres {
|
||||
slug := generateSlug(genre.Name)
|
||||
categories = append(categories, Category{
|
||||
ID: genre.ID,
|
||||
Name: genre.Name,
|
||||
Slug: slug,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: categories,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
categoryID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid category ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
if language == "" {
|
||||
language = "ru-RU"
|
||||
}
|
||||
|
||||
// Используем discover API для получения фильмов по жанру
|
||||
movies, err := h.tmdbService.DiscoverMoviesByGenre(categoryID, page, 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 generateSlug(name string) string {
|
||||
// Простая функция для создания slug из названия
|
||||
// В реальном проекте стоит использовать более сложную логику
|
||||
result := ""
|
||||
for _, char := range name {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
|
||||
result += string(char)
|
||||
} else if char == ' ' {
|
||||
result += "-"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
1332
pkg/handlers/docs.go
Normal file
1332
pkg/handlers/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
29
pkg/handlers/health.go
Normal file
29
pkg/handlers/health.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
health := map[string]interface{}{
|
||||
"status": "OK",
|
||||
"timestamp": time.Now().UTC(),
|
||||
"service": "neomovies-api",
|
||||
"version": "2.0.0",
|
||||
"uptime": time.Since(startTime),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Message: "API is running",
|
||||
Data: health,
|
||||
})
|
||||
}
|
||||
|
||||
var startTime = time.Now()
|
||||
147
pkg/handlers/images.go
Normal file
147
pkg/handlers/images.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type ImagesHandler struct{}
|
||||
|
||||
func NewImagesHandler() *ImagesHandler {
|
||||
return &ImagesHandler{}
|
||||
}
|
||||
|
||||
const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
||||
|
||||
func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
size := vars["size"]
|
||||
imagePath := vars["path"]
|
||||
|
||||
if size == "" || imagePath == "" {
|
||||
http.Error(w, "Size and path are required", http.StatusBadRequest)
|
||||
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)
|
||||
|
||||
// Получаем изображение
|
||||
resp, err := http.Get(imageURL)
|
||||
if err != nil {
|
||||
h.servePlaceholder(w, r)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
h.servePlaceholder(w, r)
|
||||
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 год
|
||||
|
||||
// Передаем изображение клиенту
|
||||
_, 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",
|
||||
"./static/placeholder.jpg",
|
||||
}
|
||||
|
||||
var placeholderPath string
|
||||
for _, path := range placeholderPaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
placeholderPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if placeholderPath == "" {
|
||||
// Если placeholder не найден, создаем простую SVG заглушку
|
||||
h.serveSVGPlaceholder(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(placeholderPath)
|
||||
if err != nil {
|
||||
h.serveSVGPlaceholder(w, r)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Определяем content-type по расширению
|
||||
ext := strings.ToLower(filepath.Ext(placeholderPath))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
case ".png":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
case ".gif":
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
case ".webp":
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
default:
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600") // кэшируем placeholder на 1 час
|
||||
|
||||
_, err = io.Copy(w, file)
|
||||
if err != nil {
|
||||
h.serveSVGPlaceholder(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) serveSVGPlaceholder(w http.ResponseWriter, r *http.Request) {
|
||||
svgPlaceholder := `<svg width="300" height="450" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#f0f0f0"/>
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" fill="#666">
|
||||
Изображение не найдено
|
||||
</text>
|
||||
</svg>`
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write([]byte(svgPlaceholder))
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) isValidSize(size string, validSizes []string) bool {
|
||||
for _, validSize := range validSizes {
|
||||
if size == validSize {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
294
pkg/handlers/movie.go
Normal file
294
pkg/handlers/movie.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type MovieHandler struct {
|
||||
movieService *services.MovieService
|
||||
}
|
||||
|
||||
func NewMovieHandler(movieService *services.MovieService) *MovieHandler {
|
||||
return &MovieHandler{
|
||||
movieService: movieService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MovieHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("query")
|
||||
if query == "" {
|
||||
http.Error(w, "Query parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
region := r.URL.Query().Get("region")
|
||||
year := getIntQuery(r, "year", 0)
|
||||
|
||||
movies, err := h.movieService.Search(query, page, language, region, year)
|
||||
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) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
movie, err := h.movieService.GetByID(id, 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: movie,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) Popular(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
region := r.URL.Query().Get("region")
|
||||
|
||||
movies, err := h.movieService.GetPopular(page, language, region)
|
||||
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) TopRated(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
region := r.URL.Query().Get("region")
|
||||
|
||||
movies, err := h.movieService.GetTopRated(page, language, region)
|
||||
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) Upcoming(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
region := r.URL.Query().Get("region")
|
||||
|
||||
movies, err := h.movieService.GetUpcoming(page, language, region)
|
||||
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) NowPlaying(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
region := r.URL.Query().Get("region")
|
||||
|
||||
movies, err := h.movieService.GetNowPlaying(page, language, region)
|
||||
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) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
movies, err := h.movieService.GetRecommendations(id, page, 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) GetSimilar(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
movies, err := h.movieService.GetSimilar(id, page, 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) 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"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
externalIDs, err := h.movieService.GetExternalIDs(id)
|
||||
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: externalIDs,
|
||||
})
|
||||
}
|
||||
|
||||
func getIntQuery(r *http.Request, key string, defaultValue int) int {
|
||||
str := r.URL.Query().Get(key)
|
||||
if str == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(str)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
142
pkg/handlers/players.go
Normal file
142
pkg/handlers/players.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"neomovies-api/pkg/config"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type PlayersHandler struct {
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewPlayersHandler(cfg *config.Config) *PlayersHandler {
|
||||
return &PlayersHandler{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("GetAllohaPlayer called: %s %s", r.Method, r.URL.Path)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
log.Printf("Route vars: %+v", vars)
|
||||
|
||||
imdbID := vars["imdb_id"]
|
||||
if imdbID == "" {
|
||||
log.Printf("Error: imdb_id is empty")
|
||||
http.Error(w, "imdb_id path param is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Processing imdb_id: %s", imdbID)
|
||||
|
||||
if h.config.AllohaToken == "" {
|
||||
log.Printf("Error: ALLOHA_TOKEN is missing")
|
||||
http.Error(w, "Server misconfiguration: ALLOHA_TOKEN missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
idParam := fmt.Sprintf("imdb=%s", url.QueryEscape(imdbID))
|
||||
apiURL := fmt.Sprintf("https://api.alloha.tv/?token=%s&%s", h.config.AllohaToken, idParam)
|
||||
log.Printf("Calling Alloha API: %s", apiURL)
|
||||
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
log.Printf("Error calling Alloha API: %v", err)
|
||||
http.Error(w, "Failed to fetch from Alloha API", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
log.Printf("Alloha API response status: %d", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, fmt.Sprintf("Alloha API error: %d", resp.StatusCode), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Error reading Alloha response: %v", err)
|
||||
http.Error(w, "Failed to read Alloha response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alloha API response body: %s", string(body))
|
||||
|
||||
var allohaResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Iframe string `json:"iframe"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &allohaResponse); err != nil {
|
||||
log.Printf("Error unmarshaling JSON: %v", err)
|
||||
http.Error(w, "Invalid JSON from Alloha", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
if allohaResponse.Status != "success" || allohaResponse.Data.Iframe == "" {
|
||||
log.Printf("Video not found or empty iframe")
|
||||
http.Error(w, "Video not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
iframeCode := allohaResponse.Data.Iframe
|
||||
if !strings.Contains(iframeCode, "<") {
|
||||
iframeCode = fmt.Sprintf(`<iframe src="%s" allowfullscreen style="border:none;width:100%%;height:100%%"></iframe>`, iframeCode)
|
||||
}
|
||||
|
||||
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Alloha Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframeCode)
|
||||
|
||||
// Авто-исправление экранированных кавычек
|
||||
htmlDoc = strings.ReplaceAll(htmlDoc, `\"`, `"`)
|
||||
htmlDoc = strings.ReplaceAll(htmlDoc, `\'`, `'`)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(htmlDoc))
|
||||
|
||||
log.Printf("Successfully served Alloha player for imdb_id: %s", imdbID)
|
||||
}
|
||||
|
||||
func (h *PlayersHandler) GetLumexPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("GetLumexPlayer called: %s %s", r.Method, r.URL.Path)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
log.Printf("Route vars: %+v", vars)
|
||||
|
||||
imdbID := vars["imdb_id"]
|
||||
if imdbID == "" {
|
||||
log.Printf("Error: imdb_id is empty")
|
||||
http.Error(w, "imdb_id path param is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Processing imdb_id: %s", imdbID)
|
||||
|
||||
if h.config.LumexURL == "" {
|
||||
log.Printf("Error: LUMEX_URL is missing")
|
||||
http.Error(w, "Server misconfiguration: LUMEX_URL missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s?imdb_id=%s", h.config.LumexURL, url.QueryEscape(imdbID))
|
||||
log.Printf("Generated Lumex URL: %s", url)
|
||||
|
||||
iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, url)
|
||||
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Lumex Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframe)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(htmlDoc))
|
||||
|
||||
log.Printf("Successfully served Lumex player for imdb_id: %s", imdbID)
|
||||
}
|
||||
171
pkg/handlers/reactions.go
Normal file
171
pkg/handlers/reactions.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type ReactionsHandler struct {
|
||||
reactionsService *services.ReactionsService
|
||||
}
|
||||
|
||||
func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler {
|
||||
return &ReactionsHandler{
|
||||
reactionsService: reactionsService,
|
||||
}
|
||||
}
|
||||
|
||||
// Получить счетчики реакций для медиа (публичный эндпоинт)
|
||||
func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
mediaType := vars["mediaType"]
|
||||
mediaID := vars["mediaId"]
|
||||
|
||||
if mediaType == "" || mediaID == "" {
|
||||
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
counts, err := h.reactionsService.GetReactionCounts(mediaType, mediaID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(counts)
|
||||
}
|
||||
|
||||
// Получить реакцию текущего пользователя (требует авторизации)
|
||||
func (h *ReactionsHandler) GetMyReaction(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)
|
||||
mediaType := vars["mediaType"]
|
||||
mediaID := vars["mediaId"]
|
||||
|
||||
if mediaType == "" || mediaID == "" {
|
||||
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reaction, err := h.reactionsService.GetUserReaction(userID, mediaType, mediaID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if reaction == nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(reaction)
|
||||
}
|
||||
}
|
||||
|
||||
// Установить реакцию пользователя (требует авторизации)
|
||||
func (h *ReactionsHandler) SetReaction(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)
|
||||
mediaType := vars["mediaType"]
|
||||
mediaID := vars["mediaId"]
|
||||
|
||||
if mediaType == "" || mediaID == "" {
|
||||
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
// Удалить реакцию пользователя (требует авторизации)
|
||||
func (h *ReactionsHandler) RemoveReaction(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)
|
||||
mediaType := vars["mediaType"]
|
||||
mediaID := vars["mediaId"]
|
||||
|
||||
if mediaType == "" || mediaID == "" {
|
||||
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.reactionsService.RemoveUserReaction(userID, mediaType, mediaID)
|
||||
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: "Reaction removed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// Получить все реакции пользователя (требует авторизации)
|
||||
func (h *ReactionsHandler) GetMyReactions(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
|
||||
}
|
||||
|
||||
limit := getIntQuery(r, "limit", 50)
|
||||
|
||||
reactions, err := h.reactionsService.GetUserReactions(userID, limit)
|
||||
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: reactions,
|
||||
})
|
||||
}
|
||||
45
pkg/handlers/search.go
Normal file
45
pkg/handlers/search.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type SearchHandler struct {
|
||||
tmdbService *services.TMDBService
|
||||
}
|
||||
|
||||
func NewSearchHandler(tmdbService *services.TMDBService) *SearchHandler {
|
||||
return &SearchHandler{
|
||||
tmdbService: tmdbService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SearchHandler) MultiSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("query")
|
||||
if query == "" {
|
||||
http.Error(w, "Query parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
if language == "" {
|
||||
language = "ru-RU"
|
||||
}
|
||||
|
||||
results, err := h.tmdbService.SearchMulti(query, page, 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: results,
|
||||
})
|
||||
}
|
||||
367
pkg/handlers/torrents.go
Normal file
367
pkg/handlers/torrents.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type TorrentsHandler struct {
|
||||
torrentService *services.TorrentService
|
||||
tmdbService *services.TMDBService
|
||||
}
|
||||
|
||||
func NewTorrentsHandler(torrentService *services.TorrentService, tmdbService *services.TMDBService) *TorrentsHandler {
|
||||
return &TorrentsHandler{
|
||||
torrentService: torrentService,
|
||||
tmdbService: tmdbService,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchTorrents - поиск торрентов по IMDB ID
|
||||
func (h *TorrentsHandler) SearchTorrents(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
imdbID := vars["imdbId"]
|
||||
|
||||
if imdbID == "" {
|
||||
http.Error(w, "IMDB ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Параметры запроса
|
||||
mediaType := r.URL.Query().Get("type")
|
||||
if mediaType == "" {
|
||||
mediaType = "movie"
|
||||
}
|
||||
|
||||
// Создаем опции поиска
|
||||
options := &models.TorrentSearchOptions{
|
||||
ContentType: mediaType,
|
||||
}
|
||||
|
||||
// Качество
|
||||
if quality := r.URL.Query().Get("quality"); quality != "" {
|
||||
options.Quality = strings.Split(quality, ",")
|
||||
}
|
||||
|
||||
// Минимальное и максимальное качество
|
||||
options.MinQuality = r.URL.Query().Get("minQuality")
|
||||
options.MaxQuality = r.URL.Query().Get("maxQuality")
|
||||
|
||||
// Исключаемые качества
|
||||
if excludeQualities := r.URL.Query().Get("excludeQualities"); excludeQualities != "" {
|
||||
options.ExcludeQualities = strings.Split(excludeQualities, ",")
|
||||
}
|
||||
|
||||
// HDR
|
||||
if hdr := r.URL.Query().Get("hdr"); hdr != "" {
|
||||
if hdrBool, err := strconv.ParseBool(hdr); err == nil {
|
||||
options.HDR = &hdrBool
|
||||
}
|
||||
}
|
||||
|
||||
// HEVC
|
||||
if hevc := r.URL.Query().Get("hevc"); hevc != "" {
|
||||
if hevcBool, err := strconv.ParseBool(hevc); err == nil {
|
||||
options.HEVC = &hevcBool
|
||||
}
|
||||
}
|
||||
|
||||
// Сортировка
|
||||
options.SortBy = r.URL.Query().Get("sortBy")
|
||||
if options.SortBy == "" {
|
||||
options.SortBy = "seeders"
|
||||
}
|
||||
|
||||
options.SortOrder = r.URL.Query().Get("sortOrder")
|
||||
if options.SortOrder == "" {
|
||||
options.SortOrder = "desc"
|
||||
}
|
||||
|
||||
// Группировка
|
||||
if groupByQuality := r.URL.Query().Get("groupByQuality"); groupByQuality == "true" {
|
||||
options.GroupByQuality = true
|
||||
}
|
||||
|
||||
if groupBySeason := r.URL.Query().Get("groupBySeason"); groupBySeason == "true" {
|
||||
options.GroupBySeason = true
|
||||
}
|
||||
|
||||
// Сезон для сериалов
|
||||
if season := r.URL.Query().Get("season"); season != "" {
|
||||
if seasonInt, err := strconv.Atoi(season); err == nil {
|
||||
options.Season = &seasonInt
|
||||
}
|
||||
}
|
||||
|
||||
// Поиск торрентов
|
||||
results, err := h.torrentService.SearchTorrentsByIMDbID(h.tmdbService, imdbID, mediaType, options)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Формируем ответ с группировкой если необходимо
|
||||
response := map[string]interface{}{
|
||||
"imdbId": imdbID,
|
||||
"type": mediaType,
|
||||
"total": results.Total,
|
||||
}
|
||||
|
||||
if options.Season != nil {
|
||||
response["season"] = *options.Season
|
||||
}
|
||||
|
||||
// Применяем группировку если запрошена
|
||||
if options.GroupByQuality && options.GroupBySeason {
|
||||
// Группируем сначала по сезонам, затем по качеству внутри каждого сезона
|
||||
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 {
|
||||
groups := h.torrentService.GroupByQuality(results.Results)
|
||||
response["grouped"] = true
|
||||
response["groups"] = groups
|
||||
} else if options.GroupBySeason {
|
||||
groups := h.torrentService.GroupBySeason(results.Results)
|
||||
response["grouped"] = true
|
||||
response["groups"] = groups
|
||||
} else {
|
||||
response["grouped"] = false
|
||||
response["results"] = results.Results
|
||||
}
|
||||
|
||||
if len(results.Results) == 0 {
|
||||
response["error"] = "No torrents found for this IMDB ID"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchMovies - поиск фильмов по названию
|
||||
func (h *TorrentsHandler) SearchMovies(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.URL.Query().Get("title")
|
||||
originalTitle := r.URL.Query().Get("originalTitle")
|
||||
year := r.URL.Query().Get("year")
|
||||
|
||||
if title == "" && originalTitle == "" {
|
||||
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.torrentService.SearchMovies(title, originalTitle, year)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"title": title,
|
||||
"originalTitle": originalTitle,
|
||||
"year": year,
|
||||
"type": "movie",
|
||||
"total": results.Total,
|
||||
"results": results.Results,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchSeries - поиск сериалов по названию с поддержкой сезонов
|
||||
func (h *TorrentsHandler) SearchSeries(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.URL.Query().Get("title")
|
||||
originalTitle := r.URL.Query().Get("originalTitle")
|
||||
year := r.URL.Query().Get("year")
|
||||
|
||||
if title == "" && originalTitle == "" {
|
||||
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var season *int
|
||||
if seasonStr := r.URL.Query().Get("season"); seasonStr != "" {
|
||||
if seasonInt, err := strconv.Atoi(seasonStr); err == nil {
|
||||
season = &seasonInt
|
||||
}
|
||||
}
|
||||
|
||||
results, err := h.torrentService.SearchSeries(title, originalTitle, year, season)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"title": title,
|
||||
"originalTitle": originalTitle,
|
||||
"year": year,
|
||||
"type": "series",
|
||||
"total": results.Total,
|
||||
"results": results.Results,
|
||||
}
|
||||
|
||||
if season != nil {
|
||||
response["season"] = *season
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchAnime - поиск аниме по названию
|
||||
func (h *TorrentsHandler) SearchAnime(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.URL.Query().Get("title")
|
||||
originalTitle := r.URL.Query().Get("originalTitle")
|
||||
year := r.URL.Query().Get("year")
|
||||
|
||||
if title == "" && originalTitle == "" {
|
||||
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.torrentService.SearchAnime(title, originalTitle, year)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"title": title,
|
||||
"originalTitle": originalTitle,
|
||||
"year": year,
|
||||
"type": "anime",
|
||||
"total": results.Total,
|
||||
"results": results.Results,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAvailableSeasons - получение доступных сезонов для сериала
|
||||
func (h *TorrentsHandler) GetAvailableSeasons(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.URL.Query().Get("title")
|
||||
originalTitle := r.URL.Query().Get("originalTitle")
|
||||
year := r.URL.Query().Get("year")
|
||||
|
||||
if title == "" && originalTitle == "" {
|
||||
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
seasons, err := h.torrentService.GetAvailableSeasons(title, originalTitle, year)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"title": title,
|
||||
"originalTitle": originalTitle,
|
||||
"year": year,
|
||||
"seasons": seasons,
|
||||
"total": len(seasons),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchByQuery - универсальный поиск торрентов
|
||||
func (h *TorrentsHandler) SearchByQuery(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("query")
|
||||
if query == "" {
|
||||
http.Error(w, "Query is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := r.URL.Query().Get("type")
|
||||
if contentType == "" {
|
||||
contentType = "movie"
|
||||
}
|
||||
|
||||
year := r.URL.Query().Get("year")
|
||||
|
||||
// Формируем параметры поиска
|
||||
params := map[string]string{
|
||||
"query": query,
|
||||
}
|
||||
|
||||
if year != "" {
|
||||
params["year"] = year
|
||||
}
|
||||
|
||||
// Устанавливаем тип контента и категорию
|
||||
switch contentType {
|
||||
case "movie":
|
||||
params["is_serial"] = "1"
|
||||
params["category"] = "2000"
|
||||
case "series", "tv":
|
||||
params["is_serial"] = "2"
|
||||
params["category"] = "5000"
|
||||
case "anime":
|
||||
params["is_serial"] = "5"
|
||||
params["category"] = "5070"
|
||||
}
|
||||
|
||||
results, err := h.torrentService.SearchTorrents(params)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Применяем фильтрацию по типу контента
|
||||
options := &models.TorrentSearchOptions{
|
||||
ContentType: contentType,
|
||||
}
|
||||
results.Results = h.torrentService.FilterByContentType(results.Results, options.ContentType)
|
||||
results.Total = len(results.Results)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"query": query,
|
||||
"type": contentType,
|
||||
"year": year,
|
||||
"total": results.Total,
|
||||
"results": results.Results,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
206
pkg/handlers/tv.go
Normal file
206
pkg/handlers/tv.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type TVHandler struct {
|
||||
tvService *services.TVService
|
||||
}
|
||||
|
||||
func NewTVHandler(tvService *services.TVService) *TVHandler {
|
||||
return &TVHandler{
|
||||
tvService: tvService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TVHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("query")
|
||||
if query == "" {
|
||||
http.Error(w, "Query parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
year := getIntQuery(r, "first_air_date_year", 0)
|
||||
|
||||
tvShows, err := h.tvService.Search(query, page, language, year)
|
||||
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: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShow, err := h.tvService.GetByID(id, 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: tvShow,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) Popular(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetPopular(page, 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: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) TopRated(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetTopRated(page, 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: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) OnTheAir(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetOnTheAir(page, 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: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) AiringToday(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetAiringToday(page, 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: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetRecommendations(id, page, 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: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) GetSimilar(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetSimilar(id, page, 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: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
externalIDs, err := h.tvService.GetExternalIDs(id)
|
||||
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: externalIDs,
|
||||
})
|
||||
}
|
||||
63
pkg/middleware/auth.go
Normal file
63
pkg/middleware/auth.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserIDKey contextKey = "userID"
|
||||
|
||||
func JWTAuth(secret string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Authorization header required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
http.Error(w, "Bearer token required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := claims["user_id"].(string)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid user ID in token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserIDFromContext(ctx context.Context) (string, bool) {
|
||||
userID, ok := ctx.Value(UserIDKey).(string)
|
||||
return userID, ok
|
||||
}
|
||||
292
pkg/models/movie.go
Normal file
292
pkg/models/movie.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package models
|
||||
|
||||
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"`
|
||||
BelongsToCollection *Collection `json:"belongs_to_collection,omitempty"`
|
||||
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
|
||||
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
|
||||
SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
|
||||
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
|
||||
SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"`
|
||||
CreatedBy []Creator `json:"created_by,omitempty"`
|
||||
EpisodeRunTime []int `json:"episode_run_time,omitempty"`
|
||||
Seasons []Season `json:"seasons,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
}
|
||||
|
||||
type MultiSearchResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []MultiSearchResult `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type Genre struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type GenresResponse struct {
|
||||
Genres []Genre `json:"genres"`
|
||||
}
|
||||
|
||||
type ExternalIDs struct {
|
||||
ID int `json:"id"`
|
||||
IMDbID string `json:"imdb_id"`
|
||||
TVDBID int `json:"tvdb_id,omitempty"`
|
||||
WikidataID string `json:"wikidata_id"`
|
||||
FacebookID string `json:"facebook_id"`
|
||||
InstagramID string `json:"instagram_id"`
|
||||
TwitterID string `json:"twitter_id"`
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
}
|
||||
|
||||
type ProductionCompany struct {
|
||||
ID int `json:"id"`
|
||||
LogoPath string `json:"logo_path"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry string `json:"origin_country"`
|
||||
}
|
||||
|
||||
type ProductionCountry struct {
|
||||
ISO31661 string `json:"iso_3166_1"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SpokenLanguage struct {
|
||||
EnglishName string `json:"english_name"`
|
||||
ISO6391 string `json:"iso_639_1"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Network struct {
|
||||
ID int `json:"id"`
|
||||
LogoPath string `json:"logo_path"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry string `json:"origin_country"`
|
||||
}
|
||||
|
||||
type Creator struct {
|
||||
ID int `json:"id"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Name string `json:"name"`
|
||||
Gender int `json:"gender"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
}
|
||||
|
||||
type Season struct {
|
||||
AirDate string `json:"air_date"`
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
SeasonNumber int `json:"season_number"`
|
||||
}
|
||||
|
||||
type TMDBResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []Movie `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBTVResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []TVShow `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Модели для торрентов
|
||||
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"`
|
||||
}
|
||||
|
||||
type TorrentSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Results []TorrentResult `json:"results"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// RedAPI специфичные структуры
|
||||
type RedAPIResponse struct {
|
||||
Results []RedAPITorrent `json:"Results"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type RedAPITorrentInfo struct {
|
||||
Quality interface{} `json:"quality,omitempty"` // Может быть string или number
|
||||
Voices []string `json:"voices,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
Seasons []int `json:"seasons,omitempty"`
|
||||
}
|
||||
|
||||
// Alloha API структуры для получения информации о фильмах
|
||||
type AllohaResponse struct {
|
||||
Data *AllohaData `json:"data"`
|
||||
}
|
||||
|
||||
type AllohaData struct {
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
}
|
||||
|
||||
// Опции поиска торрентов
|
||||
type TorrentSearchOptions struct {
|
||||
Season *int
|
||||
Quality []string
|
||||
MinQuality string
|
||||
MaxQuality string
|
||||
ExcludeQualities []string
|
||||
HDR *bool
|
||||
HEVC *bool
|
||||
SortBy string
|
||||
SortOrder string
|
||||
GroupByQuality bool
|
||||
GroupBySeason bool
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Модели для плееров
|
||||
type PlayerResponse struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Iframe string `json:"iframe,omitempty"`
|
||||
}
|
||||
|
||||
// Модели для реакций
|
||||
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"`
|
||||
}
|
||||
|
||||
type ReactionCounts struct {
|
||||
Fire int `json:"fire"`
|
||||
Nice int `json:"nice"`
|
||||
Think int `json:"think"`
|
||||
Bore int `json:"bore"`
|
||||
Shit int `json:"shit"`
|
||||
}
|
||||
48
pkg/models/user.go
Normal file
48
pkg/models/user.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
type VerifyEmailRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
|
||||
type ResendCodeRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
91
pkg/monitor/monitor.go
Normal file
91
pkg/monitor/monitor.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RequestMonitor создает middleware для мониторинга запросов в стиле htop
|
||||
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 != "" {
|
||||
url += "?" + r.URL.RawQuery
|
||||
}
|
||||
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,
|
||||
statusColor, ww.statusCode,
|
||||
url,
|
||||
float64(duration.Nanoseconds())/1000000,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// getStatusColor возвращает ANSI цвет для статус кода
|
||||
func getStatusColor(status int) string {
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
return "\033[32m" // Зеленый
|
||||
case status >= 300 && status < 400:
|
||||
return "\033[33m" // Желтый
|
||||
case status >= 400 && status < 500:
|
||||
return "\033[31m" // Красный
|
||||
case status >= 500:
|
||||
return "\033[35m" // Фиолетовый
|
||||
default:
|
||||
return "\033[37m" // Белый
|
||||
}
|
||||
}
|
||||
|
||||
// getMethodColor возвращает ANSI цвет для HTTP метода
|
||||
func getMethodColor(method string) string {
|
||||
switch strings.ToUpper(method) {
|
||||
case "GET":
|
||||
return "\033[34m" // Синий
|
||||
case "POST":
|
||||
return "\033[32m" // Зеленый
|
||||
case "PUT":
|
||||
return "\033[33m" // Желтый
|
||||
case "DELETE":
|
||||
return "\033[31m" // Красный
|
||||
case "PATCH":
|
||||
return "\033[36m" // Циан
|
||||
default:
|
||||
return "\033[37m" // Белый
|
||||
}
|
||||
}
|
||||
353
pkg/services/auth.go
Normal file
353
pkg/services/auth.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
db *mongo.Database
|
||||
jwtSecret string
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService) *AuthService {
|
||||
service := &AuthService{
|
||||
db: db,
|
||||
jwtSecret: jwtSecret,
|
||||
emailService: emailService,
|
||||
}
|
||||
|
||||
// Запускаем тест подключения к базе данных
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) generateVerificationCode() string {
|
||||
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
|
||||
}
|
||||
|
||||
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 минут
|
||||
|
||||
// Создаем нового пользователя (НЕ ВЕРИФИЦИРОВАННОГО)
|
||||
user := models.User{
|
||||
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(),
|
||||
}
|
||||
|
||||
_, err = collection.InsertOne(context.Background(), user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Отправляем код верификации на email
|
||||
if s.emailService != nil {
|
||||
go s.emailService.SendVerificationEmail(user.Email, code)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Registered. Check email for verification code.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(req models.LoginRequest) (*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())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.AuthResponse{
|
||||
Token: token,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err = collection.FindOne(context.Background(), bson.M{"_id": objectID}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
_, err = collection.UpdateOne(
|
||||
context.Background(),
|
||||
bson.M{"_id": objectID},
|
||||
bson.M{"$set": updates},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetUserByID(userID)
|
||||
}
|
||||
|
||||
func (s *AuthService) generateJWT(userID string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 дней
|
||||
"iat": time.Now().Unix(),
|
||||
"jti": uuid.New().String(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.jwtSecret))
|
||||
}
|
||||
|
||||
// Верификация email
|
||||
func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
if user.Verified {
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Email already verified",
|
||||
}, 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},
|
||||
bson.M{
|
||||
"$set": bson.M{"verified": true},
|
||||
"$unset": bson.M{
|
||||
"verificationCode": "",
|
||||
"verificationExpires": "",
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Email verified successfully",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Повторная отправка кода верификации
|
||||
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
if user.Verified {
|
||||
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},
|
||||
bson.M{
|
||||
"$set": bson.M{
|
||||
"verificationCode": code,
|
||||
"verificationExpires": codeExpires,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Отправляем новый код на email
|
||||
if s.emailService != nil {
|
||||
go s.emailService.SendVerificationEmail(user.Email, code)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Verification code sent to your email",
|
||||
}, nil
|
||||
}
|
||||
150
pkg/services/email.go
Normal file
150
pkg/services/email.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"neomovies-api/pkg/config"
|
||||
)
|
||||
|
||||
type EmailService struct {
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewEmailService(cfg *config.Config) *EmailService {
|
||||
return &EmailService{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
type EmailOptions struct {
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
IsHTML bool
|
||||
}
|
||||
|
||||
func (s *EmailService) SendEmail(options *EmailOptions) error {
|
||||
if s.config.GmailUser == "" || s.config.GmailPassword == "" {
|
||||
return fmt.Errorf("Gmail credentials not configured")
|
||||
}
|
||||
|
||||
// Gmail SMTP конфигурация
|
||||
smtpHost := "smtp.gmail.com"
|
||||
smtpPort := "587"
|
||||
auth := smtp.PlainAuth("", s.config.GmailUser, s.config.GmailPassword, smtpHost)
|
||||
|
||||
// Создаем заголовки email
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = s.config.GmailUser
|
||||
headers["To"] = strings.Join(options.To, ",")
|
||||
headers["Subject"] = options.Subject
|
||||
|
||||
if options.IsHTML {
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=UTF-8"
|
||||
}
|
||||
|
||||
// Формируем сообщение
|
||||
message := ""
|
||||
for key, value := range headers {
|
||||
message += fmt.Sprintf("%s: %s\r\n", key, value)
|
||||
}
|
||||
message += "\r\n" + options.Body
|
||||
|
||||
// Отправляем email
|
||||
err := smtp.SendMail(
|
||||
smtpHost+":"+smtpPort,
|
||||
auth,
|
||||
s.config.GmailUser,
|
||||
options.To,
|
||||
[]byte(message),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Предустановленные шаблоны email
|
||||
func (s *EmailService) SendVerificationEmail(userEmail, code string) error {
|
||||
options := &EmailOptions{
|
||||
To: []string{userEmail},
|
||||
Subject: "Подтверждение регистрации Neo Movies",
|
||||
Body: fmt.Sprintf(`
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #2196f3;">Neo Movies</h1>
|
||||
<p>Здравствуйте!</p>
|
||||
<p>Для завершения регистрации введите этот код:</p>
|
||||
<div style="
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
letter-spacing: 4px;
|
||||
margin: 20px 0;
|
||||
">
|
||||
%s
|
||||
</div>
|
||||
<p>Код действителен в течение 10 минут.</p>
|
||||
<p>Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо.</p>
|
||||
</div>
|
||||
`, code),
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return s.SendEmail(options)
|
||||
}
|
||||
|
||||
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",
|
||||
Body: fmt.Sprintf(`
|
||||
<html>
|
||||
<body>
|
||||
<h2>Сброс пароля</h2>
|
||||
<p>Вы запросили сброс пароля для вашего аккаунта Neo Movies.</p>
|
||||
<p>Нажмите на ссылку ниже, чтобы создать новый пароль:</p>
|
||||
<p><a href="%s">Сбросить пароль</a></p>
|
||||
<p>Ссылка действительна в течение 1 часа.</p>
|
||||
<p>Если вы не запрашивали сброс пароля, проигнорируйте это сообщение.</p>
|
||||
<br>
|
||||
<p>С уважением,<br>Команда Neo Movies</p>
|
||||
</body>
|
||||
</html>
|
||||
`, resetURL),
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return s.SendEmail(options)
|
||||
}
|
||||
|
||||
func (s *EmailService) SendMovieRecommendationEmail(userEmail, userName string, movies []string) error {
|
||||
moviesList := ""
|
||||
for _, movie := range movies {
|
||||
moviesList += fmt.Sprintf("<li>%s</li>", movie)
|
||||
}
|
||||
|
||||
options := &EmailOptions{
|
||||
To: []string{userEmail},
|
||||
Subject: "Новые рекомендации фильмов от Neo Movies",
|
||||
Body: fmt.Sprintf(`
|
||||
<html>
|
||||
<body>
|
||||
<h2>Привет, %s!</h2>
|
||||
<p>У нас есть новые рекомендации фильмов специально для вас:</p>
|
||||
<ul>%s</ul>
|
||||
<p>Заходите в приложение, чтобы узнать больше деталей!</p>
|
||||
<br>
|
||||
<p>С уважением,<br>Команда Neo Movies</p>
|
||||
</body>
|
||||
</html>
|
||||
`, userName, moviesList),
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return s.SendEmail(options)
|
||||
}
|
||||
110
pkg/services/movie.go
Normal file
110
pkg/services/movie.go
Normal file
@@ -0,0 +1,110 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MovieService) Search(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.SearchMovies(query, page, language, region, year)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetByID(id int, language string) (*models.Movie, error) {
|
||||
return s.tmdb.GetMovie(id, language)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetPopular(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetPopularMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetTopRated(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetTopRatedMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetUpcoming(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetUpcomingMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetNowPlaying(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetNowPlayingMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetMovieRecommendations(id, page, language)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBResponse, error) {
|
||||
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)
|
||||
}
|
||||
212
pkg/services/reactions.go
Normal file
212
pkg/services/reactions.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type ReactionsService struct {
|
||||
db *mongo.Database
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewReactionsService(db *mongo.Database) *ReactionsService {
|
||||
return &ReactionsService{
|
||||
db: db,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
const CUB_API_URL = "https://cub.rip/api"
|
||||
|
||||
var VALID_REACTIONS = []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))
|
||||
if err != nil {
|
||||
return &models.ReactionCounts{}, nil // Возвращаем пустые счетчики при ошибке
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &models.ReactionCounts{}, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return &models.ReactionCounts{}, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Result []struct {
|
||||
Type string `json:"type"`
|
||||
Counter int `json:"counter"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return &models.ReactionCounts{}, nil
|
||||
}
|
||||
|
||||
// Преобразуем в нашу структуру
|
||||
counts := &models.ReactionCounts{}
|
||||
for _, reaction := range response.Result {
|
||||
switch reaction.Type {
|
||||
case "fire":
|
||||
counts.Fire = reaction.Counter
|
||||
case "nice":
|
||||
counts.Nice = reaction.Counter
|
||||
case "think":
|
||||
counts.Think = reaction.Counter
|
||||
case "bore":
|
||||
counts.Bore = reaction.Counter
|
||||
case "shit":
|
||||
counts.Shit = reaction.Counter
|
||||
}
|
||||
}
|
||||
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// Получить реакцию пользователя для медиа
|
||||
func (s *ReactionsService) GetUserReaction(userID, mediaType, mediaID string) (*models.Reaction, error) {
|
||||
collection := s.db.Collection("reactions")
|
||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
var reaction models.Reaction
|
||||
err := collection.FindOne(context.Background(), bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": fullMediaID,
|
||||
}).Decode(&reaction)
|
||||
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, nil // Реакции нет
|
||||
}
|
||||
|
||||
return &reaction, err
|
||||
}
|
||||
|
||||
// Установить реакцию пользователя
|
||||
func (s *ReactionsService) SetUserReaction(userID, mediaType, mediaID, reactionType string) error {
|
||||
// Проверяем валидность типа реакции
|
||||
if !s.isValidReactionType(reactionType) {
|
||||
return fmt.Errorf("invalid reaction type: %s", reactionType)
|
||||
}
|
||||
|
||||
collection := s.db.Collection("reactions")
|
||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
// Создаем или обновляем реакцию
|
||||
filter := bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": fullMediaID,
|
||||
}
|
||||
|
||||
reaction := models.Reaction{
|
||||
UserID: userID,
|
||||
MediaID: fullMediaID,
|
||||
Type: reactionType,
|
||||
Created: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
update := bson.M{
|
||||
"$set": reaction,
|
||||
}
|
||||
|
||||
upsert := true
|
||||
_, err := collection.UpdateOne(context.Background(), filter, update, &options.UpdateOptions{
|
||||
Upsert: &upsert,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Отправляем реакцию в cub.rip API
|
||||
go s.sendReactionToCub(fullMediaID, reactionType)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Удалить реакцию пользователя
|
||||
func (s *ReactionsService) RemoveUserReaction(userID, mediaType, mediaID string) error {
|
||||
collection := s.db.Collection("reactions")
|
||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
_, err := collection.DeleteOne(context.Background(), bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": fullMediaID,
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Получить все реакции пользователя
|
||||
func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models.Reaction, error) {
|
||||
collection := s.db.Collection("reactions")
|
||||
|
||||
ctx := context.Background()
|
||||
cursor, err := collection.Find(ctx, bson.M{"userId": userID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var reactions []models.Reaction
|
||||
if err := cursor.All(ctx, &reactions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reactions, nil
|
||||
}
|
||||
|
||||
func (s *ReactionsService) isValidReactionType(reactionType string) bool {
|
||||
for _, valid := range VALID_REACTIONS {
|
||||
if valid == reactionType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Отправка реакции в cub.rip API (асинхронно)
|
||||
func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
|
||||
// Формируем запрос к cub.rip API
|
||||
url := fmt.Sprintf("%s/reactions/set", CUB_API_URL)
|
||||
|
||||
data := map[string]string{
|
||||
"mediaId": mediaID,
|
||||
"type": reactionType,
|
||||
}
|
||||
|
||||
_, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
479
pkg/services/tmdb.go
Normal file
479
pkg/services/tmdb.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type TMDBService struct {
|
||||
accessToken string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewTMDBService(accessToken string) *TMDBService {
|
||||
return &TMDBService{
|
||||
accessToken: accessToken,
|
||||
baseURL: "https://api.themoviedb.org/3",
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TMDBService) makeRequest(endpoint string, target interface{}) error {
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Используем Bearer токен вместо API key в query параметрах
|
||||
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("TMDB API error: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchMovies(query string, page int, language, region string, year int) (*models.TMDBResponse, 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 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
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchMulti(query string, page int, language string) (*models.MultiSearchResponse, 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")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/search/multi?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.MultiSearchResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Фильтруем результаты: убираем "person", и без названия
|
||||
filteredResults := make([]models.MultiSearchResult, 0)
|
||||
for _, result := range response.Results {
|
||||
if result.MediaType == "person" {
|
||||
continue
|
||||
}
|
||||
|
||||
hasTitle := false
|
||||
if result.MediaType == "movie" && result.Title != "" {
|
||||
hasTitle = true
|
||||
} else if result.MediaType == "tv" && result.Name != "" {
|
||||
hasTitle = true
|
||||
}
|
||||
|
||||
if hasTitle {
|
||||
filteredResults = append(filteredResults, result)
|
||||
}
|
||||
}
|
||||
|
||||
response.Results = filteredResults
|
||||
response.TotalResults = len(filteredResults)
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) {
|
||||
params := url.Values{}
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var movie models.Movie
|
||||
err := s.makeRequest(endpoint, &movie)
|
||||
return &movie, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) {
|
||||
params := url.Values{}
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var tvShow models.TVShow
|
||||
err := s.makeRequest(endpoint, &tvShow)
|
||||
return &tvShow, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) {
|
||||
params := url.Values{}
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) {
|
||||
// Получаем жанры фильмов
|
||||
movieGenres, err := s.GetGenres("movie", "ru-RU")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Получаем жанры сериалов
|
||||
tvGenres, err := s.GetGenres("tv", "ru-RU")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Объединяем жанры, убирая дубликаты
|
||||
allGenres := make(map[int]models.Genre)
|
||||
|
||||
for _, genre := range movieGenres.Genres {
|
||||
allGenres[genre.ID] = genre
|
||||
}
|
||||
|
||||
for _, genre := range tvGenres.Genres {
|
||||
allGenres[genre.ID] = genre
|
||||
}
|
||||
|
||||
// Преобразуем обратно в слайс
|
||||
var genres []models.Genre
|
||||
for _, genre := range allGenres {
|
||||
genres = append(genres, genre)
|
||||
}
|
||||
|
||||
return &models.GenresResponse{Genres: genres}, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
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 {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
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 {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
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 {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *TMDBService) DiscoverMoviesByGenre(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/movie?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
832
pkg/services/torrent.go
Normal file
832
pkg/services/torrent.go
Normal file
@@ -0,0 +1,832 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type TorrentService struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewTorrentService() *TorrentService {
|
||||
return &TorrentService{
|
||||
client: &http.Client{Timeout: 8 * time.Second},
|
||||
baseURL: "http://redapi.cfhttp.top",
|
||||
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" {
|
||||
searchParams.Add("category[]", value)
|
||||
} else {
|
||||
searchParams.Add(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var redAPIResponse models.RedAPIResponse
|
||||
if err := json.Unmarshal(body, &redAPIResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
results := s.parseRedAPIResults(redAPIResponse)
|
||||
|
||||
return &models.TorrentSearchResponse{
|
||||
Query: params["query"],
|
||||
Results: results,
|
||||
Total: len(results),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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:
|
||||
sizeStr = v
|
||||
case float64:
|
||||
sizeStr = fmt.Sprintf("%.0f", v)
|
||||
case int:
|
||||
sizeStr = fmt.Sprintf("%d", v)
|
||||
default:
|
||||
sizeStr = ""
|
||||
}
|
||||
|
||||
result := models.TorrentResult{
|
||||
Title: torrent.Title,
|
||||
Tracker: torrent.Tracker,
|
||||
Size: sizeStr,
|
||||
Seeders: torrent.Seeders,
|
||||
Peers: torrent.Peers,
|
||||
MagnetLink: torrent.MagnetUri,
|
||||
PublishDate: torrent.PublishDate,
|
||||
Category: torrent.CategoryDesc,
|
||||
Details: torrent.Details,
|
||||
Source: "RedAPI",
|
||||
}
|
||||
|
||||
// Добавляем информацию из Info если она есть
|
||||
if torrent.Info != nil {
|
||||
// Обрабатываем качество - может быть строкой или числом
|
||||
switch v := torrent.Info.Quality.(type) {
|
||||
case string:
|
||||
result.Quality = v
|
||||
case float64:
|
||||
result.Quality = fmt.Sprintf("%.0fp", v)
|
||||
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
|
||||
|
||||
// Устанавливаем тип контента и категорию
|
||||
switch mediaType {
|
||||
case "movie":
|
||||
params["is_serial"] = "1"
|
||||
params["category"] = "2000"
|
||||
case "tv", "series":
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// SearchMovies - поиск фильмов с дополнительной фильтрацией
|
||||
func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
|
||||
params := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"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
|
||||
}
|
||||
|
||||
// SearchSeries - поиск сериалов с поддержкой fallback и фильтрации по сезону
|
||||
func (s *TorrentService) SearchSeries(title, originalTitle, year string, season *int) (*models.TorrentSearchResponse, error) {
|
||||
params := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"is_serial": "2",
|
||||
"category": "5000",
|
||||
}
|
||||
if season != nil {
|
||||
params["season"] = strconv.Itoa(*season)
|
||||
}
|
||||
|
||||
response, err := s.SearchTorrents(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Если указан сезон и результатов мало, делаем fallback-поиск без сезона и фильтруем на клиенте
|
||||
if season != nil && len(response.Results) < 5 {
|
||||
paramsNoSeason := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"is_serial": "2",
|
||||
"category": "5000",
|
||||
}
|
||||
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
|
||||
if err == nil {
|
||||
filtered := s.filterBySeason(fallbackResp.Results, *season)
|
||||
// Объединяем и убираем дубликаты по MagnetLink
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
response.Results = s.FilterByContentType(response.Results, "serial")
|
||||
response.Total = len(response.Results)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// filterBySeason - фильтрация результатов по сезону (аналогично JS)
|
||||
func (s *TorrentService) filterBySeason(results []models.TorrentResult, season int) []models.TorrentResult {
|
||||
if season == 0 {
|
||||
return results
|
||||
}
|
||||
filtered := make([]models.TorrentResult, 0, len(results))
|
||||
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||
for _, torrent := range results {
|
||||
found := false
|
||||
// Проверяем поле seasons
|
||||
for _, s := range torrent.Seasons {
|
||||
if s == season {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
filtered = append(filtered, torrent)
|
||||
continue
|
||||
}
|
||||
// Проверяем в названии
|
||||
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber == season {
|
||||
filtered = append(filtered, torrent)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// SearchAnime - поиск аниме
|
||||
func (s *TorrentService) SearchAnime(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
|
||||
params := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"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
|
||||
}
|
||||
|
||||
// AllohaResponse - структура ответа от Alloha API
|
||||
type AllohaResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Year int `json:"year"`
|
||||
Category int `json:"category"` // 1-фильм, 2-сериал
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// getMovieInfoByIMDB - получение информации через Alloha API (как в JavaScript версии)
|
||||
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
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
var allohaResponse AllohaResponse
|
||||
if err := json.Unmarshal(body, &allohaResponse); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if allohaResponse.Status != "success" {
|
||||
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
|
||||
}
|
||||
|
||||
title := allohaResponse.Data.Name
|
||||
originalTitle := allohaResponse.Data.OriginalName
|
||||
year := ""
|
||||
if allohaResponse.Data.Year > 0 {
|
||||
year = strconv.Itoa(allohaResponse.Data.Year)
|
||||
}
|
||||
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
// getTitleFromTMDB - получение информации из TMDB (с fallback на Alloha API)
|
||||
func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, mediaType string) (string, string, string, error) {
|
||||
// Сначала пробуем Alloha API (как в JavaScript версии)
|
||||
title, originalTitle, year, err := s.getMovieInfoByIMDB(imdbID)
|
||||
if err == nil {
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
// Если 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
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("external_source", "imdb_id")
|
||||
params.Set("language", "ru-RU")
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tmdbService.accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
var findResponse struct {
|
||||
MovieResults []struct {
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
} `json:"movie_results"`
|
||||
TVResults []struct {
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
} `json:"tv_results"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &findResponse); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if mediaType == "movie" && len(findResponse.MovieResults) > 0 {
|
||||
movie := findResponse.MovieResults[0]
|
||||
title := movie.Title
|
||||
originalTitle := movie.OriginalTitle
|
||||
year := ""
|
||||
if movie.ReleaseDate != "" {
|
||||
year = movie.ReleaseDate[:4]
|
||||
}
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
if (mediaType == "tv" || mediaType == "series") && len(findResponse.TVResults) > 0 {
|
||||
tv := findResponse.TVResults[0]
|
||||
title := tv.Name
|
||||
originalTitle := tv.OriginalName
|
||||
year := ""
|
||||
if tv.FirstAirDate != "" {
|
||||
year = tv.FirstAirDate[:4]
|
||||
}
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
|
||||
}
|
||||
|
||||
// FilterByContentType - фильтрация по типу контента
|
||||
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 {
|
||||
switch contentType {
|
||||
case "movie":
|
||||
if s.containsAny(torrent.Types, []string{"movie", "multfilm", "documovie"}) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "serial":
|
||||
if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "anime":
|
||||
if s.contains(torrent.Types, "anime") {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Фильтрация по названию, если types недоступно
|
||||
title := strings.ToLower(torrent.Title)
|
||||
switch contentType {
|
||||
case "movie":
|
||||
if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "serial":
|
||||
if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "anime":
|
||||
if torrent.Category == "TV/Anime" || regexp.MustCompile(`(?i)anime`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// FilterTorrents - фильтрация торрентов по опциям
|
||||
func (s *TorrentService) FilterTorrents(torrents []models.TorrentResult, options *models.TorrentSearchOptions) []models.TorrentResult {
|
||||
if options == nil {
|
||||
return torrents
|
||||
}
|
||||
|
||||
var filtered []models.TorrentResult
|
||||
|
||||
for _, torrent := range torrents {
|
||||
// Фильтрация по качеству
|
||||
if len(options.Quality) > 0 {
|
||||
found := false
|
||||
for _, quality := range options.Quality {
|
||||
if strings.EqualFold(torrent.Quality, quality) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по минимальному качеству
|
||||
if options.MinQuality != "" && !s.qualityMeetsMinimum(torrent.Quality, options.MinQuality) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Фильтрация по максимальному качеству
|
||||
if options.MaxQuality != "" && !s.qualityMeetsMaximum(torrent.Quality, options.MaxQuality) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Исключение качеств
|
||||
if len(options.ExcludeQualities) > 0 {
|
||||
excluded := false
|
||||
for _, excludeQuality := range options.ExcludeQualities {
|
||||
if strings.EqualFold(torrent.Quality, excludeQuality) {
|
||||
excluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if excluded {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по HDR
|
||||
if options.HDR != nil {
|
||||
hasHDR := regexp.MustCompile(`(?i)(hdr|dolby.vision|dv)`).MatchString(torrent.Title)
|
||||
if *options.HDR != hasHDR {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по HEVC
|
||||
if options.HEVC != nil {
|
||||
hasHEVC := regexp.MustCompile(`(?i)(hevc|h\.265|x265)`).MatchString(torrent.Title)
|
||||
if *options.HEVC != hasHEVC {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по сезону (дополнительная на клиенте)
|
||||
if options.Season != nil {
|
||||
if !s.matchesSeason(torrent, *options.Season) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// matchesSeason - проверка соответствия сезону
|
||||
func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int) bool {
|
||||
// Проверяем в поле seasons
|
||||
for _, s := range torrent.Seasons {
|
||||
if s == season {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем в названии
|
||||
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber == season {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractQuality - извлечение качества из названия
|
||||
func (s *TorrentService) ExtractQuality(title string) string {
|
||||
title = strings.ToUpper(title)
|
||||
|
||||
qualityPatterns := []struct {
|
||||
pattern string
|
||||
quality string
|
||||
}{
|
||||
{`2160P|4K`, "2160p"},
|
||||
{`1440P`, "1440p"},
|
||||
{`1080P`, "1080p"},
|
||||
{`720P`, "720p"},
|
||||
{`480P`, "480p"},
|
||||
{`360P`, "360p"},
|
||||
}
|
||||
|
||||
for _, qp := range qualityPatterns {
|
||||
if matched, _ := regexp.MatchString(qp.pattern, title); matched {
|
||||
if qp.quality == "2160p" {
|
||||
return "4K"
|
||||
}
|
||||
return qp.quality
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// sortTorrents - сортировка результатов
|
||||
func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, sortOrder string) []models.TorrentResult {
|
||||
if sortBy == "" {
|
||||
sortBy = "seeders"
|
||||
}
|
||||
if sortOrder == "" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
|
||||
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
|
||||
default:
|
||||
less = torrents[i].Seeders < torrents[j].Seeders
|
||||
}
|
||||
|
||||
if sortOrder == "asc" {
|
||||
return less
|
||||
}
|
||||
return !less
|
||||
})
|
||||
|
||||
return torrents
|
||||
}
|
||||
|
||||
// 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)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber > 0 {
|
||||
seasons[seasonNumber] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Если сезоны не найдены, добавляем в группу "unknown"
|
||||
if len(seasons) == 0 {
|
||||
groups["Неизвестно"] = append(groups["Неизвестно"], torrent)
|
||||
} else {
|
||||
// Добавляем торрент во все соответствующие группы сезонов
|
||||
for season := range seasons {
|
||||
seasonKey := fmt.Sprintf("Сезон %d", season)
|
||||
// Проверяем дубликаты
|
||||
found := false
|
||||
for _, existing := range groups[seasonKey] {
|
||||
if existing.MagnetLink == torrent.MagnetLink {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
groups[seasonKey] = append(groups[seasonKey], torrent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем торренты внутри каждой группы по сидам
|
||||
for season := range groups {
|
||||
sort.Slice(groups[season], func(i, j int) bool {
|
||||
return groups[season][i].Seeders > groups[season][j].Seeders
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// GetAvailableSeasons - получение доступных сезонов для сериала
|
||||
func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string) ([]int, error) {
|
||||
response, err := s.SearchSeries(title, originalTitle, year, nil)
|
||||
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)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber > 0 {
|
||||
seasonsSet[seasonNumber] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var seasons []int
|
||||
for season := range seasonsSet {
|
||||
seasons = append(seasons, season)
|
||||
}
|
||||
|
||||
sort.Ints(seasons)
|
||||
return seasons, 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)]
|
||||
|
||||
return currentLevel >= minLevel
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
return currentLevel <= maxLevel
|
||||
}
|
||||
|
||||
func (s *TorrentService) compareSizes(size1, size2 string) bool {
|
||||
// Простое сравнение размеров (можно улучшить)
|
||||
return len(size1) < len(size2)
|
||||
}
|
||||
|
||||
func (s *TorrentService) contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *TorrentService) containsAny(slice []string, items []string) bool {
|
||||
for _, item := range items {
|
||||
if s.contains(slice, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
55
pkg/services/tv.go
Normal file
55
pkg/services/tv.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type TVService struct {
|
||||
db *mongo.Database
|
||||
tmdb *TMDBService
|
||||
}
|
||||
|
||||
func NewTVService(db *mongo.Database, tmdb *TMDBService) *TVService {
|
||||
return &TVService{
|
||||
db: db,
|
||||
tmdb: tmdb,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TVService) Search(query string, page int, language string, year int) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.SearchTVShows(query, page, language, year)
|
||||
}
|
||||
|
||||
func (s *TVService) GetByID(id int, language string) (*models.TVShow, error) {
|
||||
return s.tmdb.GetTVShow(id, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetPopular(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetPopularTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetTopRated(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetTopRatedTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetOnTheAir(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetOnTheAirTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetAiringToday(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetAiringTodayTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetTVRecommendations(id, page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetSimilarTVShows(id, page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||
return s.tmdb.GetTVExternalIDs(id)
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
const axios = require('axios');
|
||||
|
||||
class TMDBClient {
|
||||
constructor(accessToken) {
|
||||
if (!accessToken) {
|
||||
throw new Error('TMDB access token is required');
|
||||
}
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: 'https://api.themoviedb.org/3',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
console.error('TMDB API Error:', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async makeRequest(method, endpoint, options = {}) {
|
||||
try {
|
||||
// Здесь была ошибка - если передать {params: {...}} в options,
|
||||
// то мы создаем вложенный объект params.params
|
||||
const clientOptions = {
|
||||
method,
|
||||
url: endpoint,
|
||||
...options
|
||||
};
|
||||
|
||||
// Если не передали params, добавляем базовые
|
||||
if (!clientOptions.params) {
|
||||
clientOptions.params = {};
|
||||
}
|
||||
|
||||
// Добавляем базовые параметры, если их еще нет
|
||||
if (!clientOptions.params.language) {
|
||||
clientOptions.params.language = 'ru-RU';
|
||||
}
|
||||
|
||||
if (!clientOptions.params.region) {
|
||||
clientOptions.params.region = 'RU';
|
||||
}
|
||||
|
||||
console.log('TMDB Request:', {
|
||||
method,
|
||||
endpoint,
|
||||
options: clientOptions
|
||||
});
|
||||
|
||||
const response = await this.client(clientOptions);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('TMDB Error:', {
|
||||
endpoint,
|
||||
params,
|
||||
error: error.message,
|
||||
response: error.response?.data
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getImageURL(path, size = 'original') {
|
||||
if (!path) return null;
|
||||
return `https://image.tmdb.org/t/p/${size}${path}`;
|
||||
}
|
||||
|
||||
isReleased(releaseDate) {
|
||||
if (!releaseDate) return false;
|
||||
|
||||
// Если дата в будущем формате (с "г."), пропускаем фильм
|
||||
if (releaseDate.includes(' г.')) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearStr = releaseDate.split(' ')[2];
|
||||
const year = parseInt(yearStr, 10);
|
||||
return year <= currentYear;
|
||||
}
|
||||
|
||||
// Для ISO дат
|
||||
const date = new Date(releaseDate);
|
||||
if (isNaN(date.getTime())) return true; // Если не смогли распарсить, пропускаем
|
||||
|
||||
const currentDate = new Date();
|
||||
return date <= currentDate;
|
||||
}
|
||||
|
||||
filterAndProcessResults(results, type) {
|
||||
// Проверяем, что результаты - это массив
|
||||
if (!Array.isArray(results)) {
|
||||
console.error('Expected results to be an array, got:', typeof results);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`Filtering ${type}s, total before:`, results.length);
|
||||
|
||||
const filteredResults = results.filter(item => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
console.log('Skipping invalid item object');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверяем название (для фильмов - title, для сериалов - name)
|
||||
const title = type === 'movie' ? item.title : item.name;
|
||||
|
||||
// Убираем проверку на кириллицу, разрешаем любые названия
|
||||
if (!title) {
|
||||
console.log(`Skipping ${type} - no title`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверяем рейтинг, но снижаем требования
|
||||
// Разрешаем любой рейтинг, даже если он равен 0
|
||||
// Это позволит находить новые фильмы и сериалы без рейтинга
|
||||
if (item.vote_average === undefined) {
|
||||
console.log(`Skipping ${type} - no rating info:`, title);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log(`${type}s after filtering:`, filteredResults.length);
|
||||
|
||||
return filteredResults.map(item => ({
|
||||
...item,
|
||||
poster_path: this.getImageURL(item.poster_path, 'w500'),
|
||||
backdrop_path: this.getImageURL(item.backdrop_path, 'original')
|
||||
}));
|
||||
}
|
||||
|
||||
async searchMovies(query, page = 1) {
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
console.log('Searching movies:', { query, page: pageNum });
|
||||
|
||||
try {
|
||||
// Сначала пробуем поиск по стандартному запросу
|
||||
const response = await this.makeRequest('GET', '/search/movie', {
|
||||
params: {
|
||||
query,
|
||||
page: pageNum,
|
||||
include_adult: false
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
data.results = this.filterAndProcessResults(data.results, 'movie');
|
||||
|
||||
// Если нет результатов, попробуем поиск по альтернативным параметрам
|
||||
if (data.results.length === 0 && query) {
|
||||
console.log('No results from primary search, trying alternative search...');
|
||||
|
||||
// Выполним поиск по популярным фильмам и отфильтруем результаты локально
|
||||
const popularResponse = await this.makeRequest('GET', '/movie/popular', {
|
||||
params: {
|
||||
page: 1,
|
||||
region: '', // Снимаем ограничение региона
|
||||
language: 'ru-RU'
|
||||
}
|
||||
});
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
const filteredResults = popularResponse.data.results.filter(movie => {
|
||||
// Проверяем совпадение в названии и оригинальном названии
|
||||
const titleMatch = (movie.title || '').toLowerCase().includes(queryLower);
|
||||
const originalTitleMatch = (movie.original_title || '').toLowerCase().includes(queryLower);
|
||||
return titleMatch || originalTitleMatch;
|
||||
});
|
||||
|
||||
console.log(`Found ${filteredResults.length} results in alternative search`);
|
||||
|
||||
if (filteredResults.length > 0) {
|
||||
data.results = this.filterAndProcessResults(filteredResults, 'movie');
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error in searchMovies:', error);
|
||||
// Возвращаем пустой результат в случае ошибки
|
||||
return { results: [], total_results: 0, total_pages: 0, page: pageNum };
|
||||
}
|
||||
}
|
||||
|
||||
async getPopularMovies(page = 1) {
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
console.log('Getting popular movies:', { page: pageNum });
|
||||
|
||||
const response = await this.makeRequest('GET', '/movie/popular', {
|
||||
params: {
|
||||
page: pageNum
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
data.results = this.filterAndProcessResults(data.results, 'movie');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getTopRatedMovies(page = 1) {
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
const response = await this.makeRequest('GET', '/movie/top_rated', {
|
||||
params: {
|
||||
page: pageNum
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
data.results = this.filterAndProcessResults(data.results, 'movie');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getUpcomingMovies(page = 1) {
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
const response = await this.makeRequest('GET', '/movie/upcoming', {
|
||||
params: {
|
||||
page: pageNum
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
data.results = this.filterAndProcessResults(data.results, 'movie');
|
||||
return data;
|
||||
}
|
||||
|
||||
async getMovie(id) {
|
||||
const response = await this.makeRequest('GET', `/movie/${id}`);
|
||||
const movie = response.data;
|
||||
return {
|
||||
...movie,
|
||||
poster_path: this.getImageURL(movie.poster_path, 'w500'),
|
||||
backdrop_path: this.getImageURL(movie.backdrop_path, 'original')
|
||||
};
|
||||
}
|
||||
|
||||
async getMovieExternalIDs(id) {
|
||||
const response = await this.makeRequest('GET', `/movie/${id}/external_ids`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getMovieVideos(id) {
|
||||
const response = await this.makeRequest('GET', `/movie/${id}/videos`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Получение жанров фильмов
|
||||
async getMovieGenres() {
|
||||
console.log('Getting movie genres');
|
||||
try {
|
||||
const response = await this.makeRequest('GET', '/genre/movie/list', {
|
||||
params: {
|
||||
language: 'ru'
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting movie genres:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение жанров сериалов
|
||||
async getTVGenres() {
|
||||
console.log('Getting TV genres');
|
||||
try {
|
||||
const response = await this.makeRequest('GET', '/genre/tv/list', {
|
||||
params: {
|
||||
language: 'ru'
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting TV genres:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Получение всех жанров (фильмы и сериалы)
|
||||
async getAllGenres() {
|
||||
console.log('Getting all genres (movies and TV)');
|
||||
try {
|
||||
const [movieGenres, tvGenres] = await Promise.all([
|
||||
this.getMovieGenres(),
|
||||
this.getTVGenres()
|
||||
]);
|
||||
|
||||
// Объединяем жанры, удаляя дубликаты по ID
|
||||
const allGenres = [...movieGenres.genres];
|
||||
|
||||
// Добавляем жанры сериалов, которых нет в фильмах
|
||||
tvGenres.genres.forEach(tvGenre => {
|
||||
if (!allGenres.some(genre => genre.id === tvGenre.id)) {
|
||||
allGenres.push(tvGenre);
|
||||
}
|
||||
});
|
||||
|
||||
return { genres: allGenres };
|
||||
} catch (error) {
|
||||
console.error('Error getting all genres:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getMoviesByGenre(genreId, page = 1) {
|
||||
return this.makeRequest('GET', '/discover/movie', {
|
||||
params: {
|
||||
with_genres: genreId,
|
||||
page,
|
||||
sort_by: 'popularity.desc',
|
||||
'vote_count.gte': 100,
|
||||
include_adult: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getPopularTVShows(page = 1) {
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
console.log('Getting popular TV shows:', { page: pageNum });
|
||||
|
||||
const response = await this.makeRequest('GET', '/tv/popular', {
|
||||
params: {
|
||||
page: pageNum
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
results: this.filterAndProcessResults(response.data.results, 'tv')
|
||||
};
|
||||
}
|
||||
|
||||
async searchTVShows(query, page = 1) {
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
console.log('Searching TV shows:', { query, page: pageNum });
|
||||
|
||||
try {
|
||||
// Сначала пробуем стандартный поиск
|
||||
const response = await this.makeRequest('GET', '/search/tv', {
|
||||
params: {
|
||||
query,
|
||||
page: pageNum,
|
||||
include_adult: false
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
data.results = this.filterAndProcessResults(data.results, 'tv');
|
||||
|
||||
// Если нет результатов, попробуем поиск по альтернативным параметрам
|
||||
if (data.results.length === 0 && query) {
|
||||
console.log('No results from primary TV search, trying alternative search...');
|
||||
|
||||
// Выполним поиск по популярным сериалам и отфильтруем результаты локально
|
||||
const popularResponse = await this.makeRequest('GET', '/tv/popular', {
|
||||
params: {
|
||||
page: 1,
|
||||
region: '', // Снимаем ограничение региона
|
||||
language: 'ru-RU'
|
||||
}
|
||||
});
|
||||
|
||||
const queryLower = query.toLowerCase();
|
||||
const filteredResults = popularResponse.data.results.filter(show => {
|
||||
// Проверяем совпадение в названии и оригинальном названии
|
||||
const nameMatch = (show.name || '').toLowerCase().includes(queryLower);
|
||||
const originalNameMatch = (show.original_name || '').toLowerCase().includes(queryLower);
|
||||
return nameMatch || originalNameMatch;
|
||||
});
|
||||
|
||||
console.log(`Found ${filteredResults.length} results in alternative TV search`);
|
||||
|
||||
if (filteredResults.length > 0) {
|
||||
data.results = this.filterAndProcessResults(filteredResults, 'tv');
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error in searchTVShows:', error);
|
||||
// Возвращаем пустой результат в случае ошибки
|
||||
return { results: [], total_results: 0, total_pages: 0, page: pageNum };
|
||||
}
|
||||
}
|
||||
|
||||
async getTVShow(id) {
|
||||
const response = await this.makeRequest('GET', `/tv/${id}`, {
|
||||
append_to_response: 'credits,videos,similar,external_ids'
|
||||
});
|
||||
|
||||
const show = response.data;
|
||||
return {
|
||||
...show,
|
||||
poster_path: this.getImageURL(show.poster_path, 'w500'),
|
||||
backdrop_path: this.getImageURL(show.backdrop_path, 'original'),
|
||||
credits: show.credits || { cast: [], crew: [] },
|
||||
videos: show.videos || { results: [] }
|
||||
};
|
||||
}
|
||||
|
||||
async getTVShowExternalIDs(id) {
|
||||
const response = await this.makeRequest('GET', `/tv/${id}/external_ids`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getTVShowVideos(id) {
|
||||
const response = await this.makeRequest('GET', `/tv/${id}/videos`);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TMDBClient;
|
||||
157
src/db.js
157
src/db.js
@@ -1,157 +0,0 @@
|
||||
const { MongoClient } = require('mongodb');
|
||||
|
||||
const uri = process.env.MONGODB_URI || process.env.mongodb_uri || process.env.MONGO_URI;
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('MONGODB_URI environment variable is not set');
|
||||
}
|
||||
|
||||
let client;
|
||||
let clientPromise;
|
||||
|
||||
const clientOptions = {
|
||||
maxPoolSize: 10,
|
||||
minPoolSize: 0,
|
||||
// Увеличиваем таймауты для медленных соединений
|
||||
serverSelectionTimeoutMS: 60000, // 60 секунд
|
||||
socketTimeoutMS: 0, // Убираем таймаут сокета
|
||||
connectTimeoutMS: 60000, // 60 секунд
|
||||
retryWrites: true,
|
||||
w: 'majority',
|
||||
|
||||
// Добавляем настройки для лучшей стабильности
|
||||
maxIdleTimeMS: 30000,
|
||||
waitQueueTimeoutMS: 5000,
|
||||
heartbeatFrequencyMS: 10000,
|
||||
|
||||
serverApi: {
|
||||
version: '1',
|
||||
strict: true,
|
||||
deprecationErrors: true
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для создания подключения с retry логикой
|
||||
async function createConnection() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 3;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
console.log(`Attempting to connect to MongoDB (attempt ${attempts + 1}/${maxAttempts})...`);
|
||||
const client = new MongoClient(uri, clientOptions);
|
||||
await client.connect();
|
||||
|
||||
// Проверяем подключение
|
||||
await client.db().admin().ping();
|
||||
console.log('MongoDB connection successful');
|
||||
return client;
|
||||
|
||||
} catch (error) {
|
||||
attempts++;
|
||||
console.error(`Connection attempt ${attempts} failed:`, error.message);
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
throw new Error(`Failed to connect to MongoDB after ${maxAttempts} attempts: ${error.message}`);
|
||||
}
|
||||
|
||||
// Ждем перед следующей попыткой
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection management
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// В режиме разработки используем глобальную переменную
|
||||
if (!global._mongoClientPromise) {
|
||||
global._mongoClientPromise = createConnection();
|
||||
console.log('MongoDB connection initialized in development');
|
||||
}
|
||||
clientPromise = global._mongoClientPromise;
|
||||
} else {
|
||||
// В продакшене создаем новое подключение
|
||||
clientPromise = createConnection();
|
||||
console.log('MongoDB connection initialized in production');
|
||||
}
|
||||
|
||||
async function getDb() {
|
||||
try {
|
||||
const mongoClient = await clientPromise;
|
||||
|
||||
// Проверяем, что подключение все еще активно
|
||||
if (!mongoClient || mongoClient.topology.isDestroyed()) {
|
||||
throw new Error('MongoDB connection is not available');
|
||||
}
|
||||
|
||||
return mongoClient.db();
|
||||
} catch (error) {
|
||||
console.error('Error getting MongoDB database:', error);
|
||||
|
||||
// Пытаемся переподключиться
|
||||
console.log('Attempting to reconnect...');
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
global._mongoClientPromise = createConnection();
|
||||
clientPromise = global._mongoClientPromise;
|
||||
} else {
|
||||
clientPromise = createConnection();
|
||||
}
|
||||
|
||||
const mongoClient = await clientPromise;
|
||||
return mongoClient.db();
|
||||
}
|
||||
}
|
||||
|
||||
async function closeConnection() {
|
||||
try {
|
||||
const mongoClient = await clientPromise;
|
||||
if (mongoClient) {
|
||||
await mongoClient.close(true);
|
||||
console.log('MongoDB connection closed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error closing MongoDB connection:', error);
|
||||
} finally {
|
||||
client = null;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
global._mongoClientPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для проверки подключения
|
||||
async function checkConnection() {
|
||||
try {
|
||||
const db = await getDb();
|
||||
await db.admin().ping();
|
||||
console.log('MongoDB connection is healthy');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('MongoDB connection check failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up handlers
|
||||
const cleanup = async () => {
|
||||
console.log('Cleaning up MongoDB connection...');
|
||||
await closeConnection();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', cleanup);
|
||||
process.on('SIGINT', cleanup);
|
||||
|
||||
process.on('uncaughtException', async (err) => {
|
||||
console.error('Uncaught Exception:', err);
|
||||
await closeConnection();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', async (reason) => {
|
||||
console.error('Unhandled Rejection:', reason);
|
||||
await closeConnection();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
module.exports = { getDb, closeConnection, checkConnection };
|
||||
346
src/index.js
346
src/index.js
@@ -1,346 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const swaggerJsdoc = require('swagger-jsdoc');
|
||||
const path = require('path');
|
||||
const TMDBClient = require('./config/tmdb');
|
||||
const healthCheck = require('./utils/health');
|
||||
const { formatDate } = require('./utils/date');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Определяем базовый URL для документации
|
||||
const BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? 'https://neomovies-api.vercel.app'
|
||||
: 'http://localhost:3000';
|
||||
|
||||
// Swagger configuration
|
||||
const swaggerOptions = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Neo Movies API',
|
||||
version: '1.0.0',
|
||||
description: 'API для поиска и получения информации о фильмах с поддержкой русского языка',
|
||||
contact: {
|
||||
name: 'API Support',
|
||||
url: 'https://gitlab.com/foxixus/neomovies-api'
|
||||
}
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: BASE_URL,
|
||||
description: process.env.NODE_ENV === 'production' ? 'Production server' : 'Development server'
|
||||
}
|
||||
],
|
||||
security: [{ bearerAuth: [] }],
|
||||
tags: [
|
||||
{
|
||||
name: 'movies',
|
||||
description: 'Операции с фильмами'
|
||||
},
|
||||
{
|
||||
name: 'tv',
|
||||
description: 'Операции с сериалами'
|
||||
},
|
||||
{
|
||||
name: 'health',
|
||||
description: 'Проверка работоспособности API'
|
||||
},
|
||||
{
|
||||
name: 'auth',
|
||||
description: 'Операции авторизации'
|
||||
},
|
||||
{
|
||||
name: 'favorites',
|
||||
description: 'Операции с избранным'
|
||||
},
|
||||
{
|
||||
name: 'players',
|
||||
description: 'Плееры Alloha и Lumex'
|
||||
},
|
||||
{
|
||||
name: 'torrents',
|
||||
description: 'Поиск торрентов'
|
||||
}
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT'
|
||||
}
|
||||
},
|
||||
schemas: {
|
||||
Movie: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
description: 'ID фильма'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Название фильма'
|
||||
}
|
||||
}
|
||||
},
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: {
|
||||
type: 'string',
|
||||
description: 'Сообщение об ошибке'
|
||||
},
|
||||
details: {
|
||||
type: 'string',
|
||||
description: 'Детали ошибки'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
apis: [path.join(__dirname, 'routes', '*.js'), __filename]
|
||||
};
|
||||
|
||||
const swaggerDocs = swaggerJsdoc(swaggerOptions);
|
||||
|
||||
// CORS configuration
|
||||
const corsOptions = {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
credentials: true,
|
||||
optionsSuccessStatus: 200
|
||||
};
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
// Handle preflight requests
|
||||
app.options('*', cors(corsOptions));
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// TMDB client middleware
|
||||
app.use((req, res, next) => {
|
||||
try {
|
||||
const token = process.env.TMDB_ACCESS_TOKEN;
|
||||
if (!token) {
|
||||
console.error('TMDB_ACCESS_TOKEN is not set');
|
||||
return res.status(500).json({
|
||||
error: 'Server configuration error',
|
||||
details: 'API token is not configured'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Initializing TMDB client...');
|
||||
req.tmdb = new TMDBClient(token);
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize TMDB client:', error);
|
||||
res.status(500).json({
|
||||
error: 'Server initialization error',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API Documentation routes
|
||||
app.get('/api-docs', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'api-docs', 'index.html'));
|
||||
});
|
||||
|
||||
app.get('/api-docs/swagger.json', (req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.send(swaggerDocs);
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /search/multi:
|
||||
* get:
|
||||
* summary: Мультипоиск
|
||||
* description: Поиск фильмов и сериалов по запросу
|
||||
* tags: [search]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: query
|
||||
* required: true
|
||||
* description: Поисковый запрос
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Успешный поиск
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* title:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* media_type:
|
||||
* type: string
|
||||
* enum: [movie, tv]
|
||||
*/
|
||||
app.get('/search/multi', async (req, res) => {
|
||||
try {
|
||||
const { query, page = 1 } = req.query;
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: 'Query parameter is required' });
|
||||
}
|
||||
|
||||
console.log('Multi-search request:', { query, page });
|
||||
|
||||
const response = await req.tmdb.makeRequest('get', '/search/multi', {
|
||||
query,
|
||||
page,
|
||||
include_adult: false,
|
||||
language: 'ru-RU'
|
||||
});
|
||||
|
||||
if (!response.data || !response.data.results) {
|
||||
console.error('Invalid response from TMDB:', response);
|
||||
return res.status(500).json({ error: 'Invalid response from TMDB API' });
|
||||
}
|
||||
|
||||
console.log('Multi-search response:', {
|
||||
page: response.data.page,
|
||||
total_results: response.data.total_results,
|
||||
total_pages: response.data.total_pages,
|
||||
results_count: response.data.results?.length
|
||||
});
|
||||
|
||||
// Форматируем даты в результатах
|
||||
const formattedResults = response.data.results.map(item => ({
|
||||
...item,
|
||||
release_date: item.release_date ? formatDate(item.release_date) : undefined,
|
||||
first_air_date: item.first_air_date ? formatDate(item.first_air_date) : undefined
|
||||
}));
|
||||
|
||||
res.json({
|
||||
...response.data,
|
||||
results: formattedResults
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in multi-search:', error.response?.data || error.message);
|
||||
res.status(500).json({
|
||||
error: 'Failed to search',
|
||||
details: error.response?.data?.status_message || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API routes
|
||||
const moviesRouter = require('./routes/movies');
|
||||
const tvRouter = require('./routes/tv');
|
||||
const imagesRouter = require('./routes/images');
|
||||
const categoriesRouter = require('./routes/categories');
|
||||
const favoritesRouter = require('./routes/favorites');
|
||||
const playersRouter = require('./routes/players');
|
||||
const reactionsRouter = require('./routes/reactions');
|
||||
const routerToUse = reactionsRouter.default || reactionsRouter;
|
||||
require('./utils/cleanup');
|
||||
const authRouter = require('./routes/auth');
|
||||
const torrentsRouter = require('./routes/torrents');
|
||||
|
||||
app.use('/movies', moviesRouter);
|
||||
app.use('/tv', tvRouter);
|
||||
app.use('/images', imagesRouter);
|
||||
app.use('/categories', categoriesRouter);
|
||||
app.use('/favorites', favoritesRouter);
|
||||
app.use('/players', playersRouter);
|
||||
app.use('/reactions', routerToUse);
|
||||
app.use('/auth', authRouter);
|
||||
app.use('/torrents', torrentsRouter);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /health:
|
||||
* get:
|
||||
* tags: [health]
|
||||
* summary: Проверка работоспособности API
|
||||
* description: Возвращает подробную информацию о состоянии API, включая статус TMDB, использование памяти и системную информацию
|
||||
* responses:
|
||||
* 200:
|
||||
* description: API работает нормально
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [ok, error]
|
||||
* tmdb:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [ok, error]
|
||||
*/
|
||||
app.get('/health', async (req, res) => {
|
||||
try {
|
||||
const health = await healthCheck.getFullHealth(req.tmdb);
|
||||
res.json(health);
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
});
|
||||
|
||||
// Handle 404
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not Found' });
|
||||
});
|
||||
|
||||
// Export the Express API
|
||||
module.exports = app;
|
||||
|
||||
// Start server only in development
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// Проверяем аргументы командной строки
|
||||
const args = process.argv.slice(2);
|
||||
// Используем порт из аргументов командной строки, переменной окружения или по умолчанию 3000
|
||||
const port = args[0] || process.env.PORT || 3000;
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
console.log(`Documentation available at http://localhost:${port}/api-docs`);
|
||||
});
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
/**
|
||||
* Express middleware to protect routes with JWT authentication.
|
||||
* Attaches the decoded token to req.user on success.
|
||||
*/
|
||||
function authRequired(req, res, next) {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader) {
|
||||
return res.status(401).json({ error: 'Authorization header missing' });
|
||||
}
|
||||
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
return res.status(401).json({ error: 'Invalid Authorization header format' });
|
||||
}
|
||||
|
||||
const token = parts[1];
|
||||
const secret = process.env.JWT_SECRET || process.env.jwt_secret;
|
||||
if (!secret) {
|
||||
console.error('JWT_SECRET not set');
|
||||
return res.status(500).json({ error: 'Server configuration error' });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, secret);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('JWT auth error:', err);
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = authRequired;
|
||||
@@ -1,70 +0,0 @@
|
||||
<!-- HTML for static distribution bundle build -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Neo Movies API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.9.0/swagger-ui.css" />
|
||||
<link rel="icon" type="image/png" href="https://www.themoviedb.org/favicon.ico" />
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.swagger-ui .topbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.9.0/swagger-ui-bundle.js" crossorigin></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.9.0/swagger-ui-standalone-preset.js" crossorigin></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
const baseUrl = window.location.protocol + "//" + window.location.host;
|
||||
const ui = SwaggerUIBundle({
|
||||
url: baseUrl + "/api-docs/swagger.json",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
defaultModelsExpandDepth: -1,
|
||||
docExpansion: "list",
|
||||
tryItOutEnabled: true,
|
||||
requestInterceptor: (req) => {
|
||||
// Add CORS headers to all requests
|
||||
req.headers = {
|
||||
...req.headers,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
return req;
|
||||
}
|
||||
});
|
||||
window.ui = ui;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,254 +0,0 @@
|
||||
const { Router } = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { getDb } = require('../db');
|
||||
const { ObjectId } = require('mongodb');
|
||||
const { sendVerificationEmail } = require('../utils/mailer');
|
||||
const authRequired = require('../middleware/auth');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: auth
|
||||
* description: Операции авторизации
|
||||
*/
|
||||
const router = Router();
|
||||
|
||||
// Helper to generate 6-digit code
|
||||
function generateCode() {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
// Register
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/register:
|
||||
* post:
|
||||
* tags: [auth]
|
||||
* summary: Регистрация пользователя
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
|
||||
const db = await getDb();
|
||||
const existing = await db.collection('users').findOne({ email });
|
||||
if (existing) return res.status(400).json({ error: 'Email already registered' });
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
const code = generateCode();
|
||||
const codeExpires = new Date(Date.now() + 10 * 60 * 1000);
|
||||
|
||||
await db.collection('users').insertOne({
|
||||
email,
|
||||
password: hashed,
|
||||
name: name || email,
|
||||
verified: false,
|
||||
verificationCode: code,
|
||||
verificationExpires: codeExpires,
|
||||
isAdmin: false,
|
||||
adminVerified: false,
|
||||
createdAt: new Date()
|
||||
});
|
||||
|
||||
await sendVerificationEmail(email, code);
|
||||
res.json({ success: true, message: 'Registered. Check email for code.' });
|
||||
} catch (err) {
|
||||
console.error('Register error:', err);
|
||||
res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify email
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/verify:
|
||||
* post:
|
||||
* tags: [auth]
|
||||
* summary: Подтверждение email
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
router.post('/verify', async (req, res) => {
|
||||
try {
|
||||
const { email, code } = req.body;
|
||||
const db = await getDb();
|
||||
const user = await db.collection('users').findOne({ email });
|
||||
if (!user) return res.status(400).json({ error: 'User not found' });
|
||||
if (user.verified) return res.json({ success: true, message: 'Already verified' });
|
||||
if (user.verificationCode !== code || user.verificationExpires < new Date()) {
|
||||
return res.status(400).json({ error: 'Invalid or expired code' });
|
||||
}
|
||||
await db.collection('users').updateOne({ email }, { $set: { verified: true }, $unset: { verificationCode: '', verificationExpires: '' } });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Verify error:', err);
|
||||
res.status(500).json({ error: 'Verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Resend code
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/resend-code:
|
||||
* post:
|
||||
* tags: [auth]
|
||||
* summary: Повторная отправка кода подтверждения
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
router.post('/resend-code', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
const db = await getDb();
|
||||
const user = await db.collection('users').findOne({ email });
|
||||
if (!user) return res.status(400).json({ error: 'User not found' });
|
||||
const code = generateCode();
|
||||
const codeExpires = new Date(Date.now() + 10 * 60 * 1000);
|
||||
await db.collection('users').updateOne({ email }, { $set: { verificationCode: code, verificationExpires: codeExpires } });
|
||||
await sendVerificationEmail(email, code);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Resend code error:', err);
|
||||
res.status(500).json({ error: 'Failed to resend code' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/login:
|
||||
* post:
|
||||
* tags: [auth]
|
||||
* summary: Логин пользователя
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
*
|
||||
* responses:
|
||||
* 200:
|
||||
* description: JWT token
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
const db = await getDb();
|
||||
const user = await db.collection('users').findOne({ email });
|
||||
if (!user) return res.status(400).json({ error: 'User not found' });
|
||||
if (!user.verified) {
|
||||
return res.status(403).json({ error: 'Account not activated. Please verify your email.' });
|
||||
}
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) return res.status(400).json({ error: 'Invalid password' });
|
||||
const payload = {
|
||||
id: user._id.toString(),
|
||||
email: user.email,
|
||||
name: user.name || '',
|
||||
verified: user.verified,
|
||||
isAdmin: user.isAdmin,
|
||||
adminVerified: user.adminVerified
|
||||
};
|
||||
const secret = process.env.JWT_SECRET || process.env.jwt_secret;
|
||||
const token = jwt.sign(payload, secret, { expiresIn: '7d', jwtid: uuidv4() });
|
||||
|
||||
res.json({ token, user: { name: user.name || '', email: user.email } });
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete account
|
||||
/**
|
||||
* @swagger
|
||||
* /auth/profile:
|
||||
* delete:
|
||||
* tags: [auth]
|
||||
* summary: Удаление аккаунта пользователя
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Аккаунт успешно удален
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
*/
|
||||
router.delete('/profile', authRequired, async (req, res) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const userId = req.user.id;
|
||||
|
||||
// 1. Найти все реакции пользователя, чтобы уменьшить счетчики в cub.rip
|
||||
const userReactions = await db.collection('reactions').find({ userId }).toArray();
|
||||
if (userReactions.length > 0) {
|
||||
const CUB_API_URL = process.env.CUB_API_URL || 'https://cub.rip/api';
|
||||
const removalPromises = userReactions.map(reaction =>
|
||||
fetch(`${CUB_API_URL}/reactions/remove/${reaction.mediaId}/${reaction.type}`, {
|
||||
method: 'POST' // или 'DELETE', в зависимости от API
|
||||
})
|
||||
);
|
||||
await Promise.all(removalPromises);
|
||||
}
|
||||
|
||||
// 2. Удалить все данные пользователя
|
||||
await db.collection('users').deleteOne({ _id: new ObjectId(userId) });
|
||||
await db.collection('favorites').deleteMany({ userId });
|
||||
await db.collection('reactions').deleteMany({ userId });
|
||||
|
||||
res.status(200).json({ success: true, message: 'Account deleted successfully.' });
|
||||
} catch (err) {
|
||||
console.error('Delete account error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete account.' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,378 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { formatDate } = require('../utils/date');
|
||||
|
||||
// Middleware для логирования запросов
|
||||
router.use((req, res, next) => {
|
||||
console.log('Categories API Request:', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
query: req.query,
|
||||
params: req.params
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /categories:
|
||||
* get:
|
||||
* summary: Получение списка категорий
|
||||
* description: Возвращает список всех доступных категорий фильмов (жанров)
|
||||
* tags: [categories]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Список категорий
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
console.log('Fetching categories (genres)...');
|
||||
|
||||
// Получаем данные о всех жанрах из TMDB (фильмы и сериалы)
|
||||
const genresData = await req.tmdb.getAllGenres();
|
||||
|
||||
if (!genresData?.genres || !Array.isArray(genresData.genres)) {
|
||||
console.error('Invalid genres response:', genresData);
|
||||
return res.status(500).json({
|
||||
error: 'Invalid response from TMDB',
|
||||
details: 'Genres data is missing or invalid'
|
||||
});
|
||||
}
|
||||
|
||||
// Преобразуем жанры в категории
|
||||
const categories = genresData.genres.map(genre => ({
|
||||
id: genre.id,
|
||||
name: genre.name,
|
||||
slug: genre.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
}));
|
||||
|
||||
// Сортируем категории по алфавиту
|
||||
categories.sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
||||
|
||||
console.log('Categories response:', {
|
||||
count: categories.length,
|
||||
categories: categories.slice(0, 3) // логируем только первые 3 для краткости
|
||||
});
|
||||
|
||||
res.json({ categories });
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch categories',
|
||||
details: error.response?.data?.status_message || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /categories/{id}:
|
||||
* get:
|
||||
* summary: Получение категории по ID
|
||||
* description: Возвращает информацию о категории по ее ID
|
||||
* tags: [categories]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* description: ID категории (жанра)
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Категория найдена
|
||||
* 404:
|
||||
* description: Категория не найдена
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
console.log(`Fetching category (genre) with ID: ${id}`);
|
||||
|
||||
// Получаем данные о всех жанрах (фильмы и сериалы)
|
||||
const genresData = await req.tmdb.getAllGenres();
|
||||
|
||||
if (!genresData?.genres || !Array.isArray(genresData.genres)) {
|
||||
console.error('Invalid genres response:', genresData);
|
||||
return res.status(500).json({
|
||||
error: 'Invalid response from TMDB',
|
||||
details: 'Genres data is missing or invalid'
|
||||
});
|
||||
}
|
||||
|
||||
// Находим жанр по ID
|
||||
const genre = genresData.genres.find(g => g.id === parseInt(id));
|
||||
|
||||
if (!genre) {
|
||||
return res.status(404).json({
|
||||
error: 'Category not found',
|
||||
details: `No category with ID ${id}`
|
||||
});
|
||||
}
|
||||
|
||||
// Преобразуем жанр в категорию
|
||||
const category = {
|
||||
id: genre.id,
|
||||
name: genre.name,
|
||||
slug: genre.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
|
||||
moviesCount: null // Можно будет дополнительно получить количество фильмов по жанру
|
||||
};
|
||||
|
||||
res.json(category);
|
||||
} catch (error) {
|
||||
console.error('Error fetching category by ID:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch category',
|
||||
details: error.response?.data?.status_message || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /categories/{id}/movies:
|
||||
* get:
|
||||
* summary: Получение фильмов по категории
|
||||
* description: Возвращает список фильмов, принадлежащих указанной категории (жанру)
|
||||
* tags: [categories]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* description: ID категории (жанра)
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Список фильмов по категории
|
||||
* 404:
|
||||
* description: Категория не найдена
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
*/
|
||||
router.get('/:id/movies', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { page = 1 } = req.query;
|
||||
|
||||
console.log(`Fetching movies for category (genre) ID: ${id}, page: ${page}`);
|
||||
|
||||
// Проверяем существование жанра в списке всех жанров
|
||||
const genresData = await req.tmdb.getAllGenres();
|
||||
const genreExists = genresData?.genres?.some(g => g.id === parseInt(id));
|
||||
|
||||
if (!genreExists) {
|
||||
return res.status(404).json({
|
||||
error: 'Category not found',
|
||||
details: `No category with ID ${id}`
|
||||
});
|
||||
}
|
||||
|
||||
// Получаем фильмы по жанру напрямую из TMDB
|
||||
console.log(`Making TMDB request for movies with genre ID: ${id}, page: ${page}`);
|
||||
|
||||
// В URL параметрах напрямую указываем жанр, чтобы быть уверенными
|
||||
const endpoint = `/discover/movie?with_genres=${id}`;
|
||||
|
||||
const requestParams = {
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
include_adult: false,
|
||||
sort_by: 'popularity.desc'
|
||||
};
|
||||
|
||||
// Дополнительно добавляем вариации для разных жанров
|
||||
if (parseInt(id) % 2 === 0) {
|
||||
requestParams['vote_count.gte'] = 50;
|
||||
} else {
|
||||
requestParams['vote_average.gte'] = 5;
|
||||
}
|
||||
|
||||
console.log('Request params:', requestParams);
|
||||
console.log('Endpoint with genre:', endpoint);
|
||||
|
||||
const response = await req.tmdb.makeRequest('get', endpoint, {
|
||||
params: requestParams
|
||||
});
|
||||
|
||||
console.log(`TMDB response received, status: ${response.status}, has results: ${!!response?.data?.results}`);
|
||||
|
||||
if (response?.data?.results?.length > 0) {
|
||||
console.log(`First few movie IDs: ${response.data.results.slice(0, 5).map(m => m.id).join(', ')}`);
|
||||
}
|
||||
|
||||
if (!response?.data?.results) {
|
||||
console.error('Invalid movie response:', response);
|
||||
return res.status(500).json({
|
||||
error: 'Invalid response from TMDB',
|
||||
details: 'Movie data is missing'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Movies by category response:', {
|
||||
page: response.data.page,
|
||||
total_results: response.data.total_results,
|
||||
results_count: response.data.results?.length
|
||||
});
|
||||
|
||||
// Форматируем даты в результатах
|
||||
const formattedResults = response.data.results.map(movie => ({
|
||||
...movie,
|
||||
release_date: movie.release_date ? formatDate(movie.release_date) : undefined,
|
||||
poster_path: req.tmdb.getImageURL(movie.poster_path, 'w500'),
|
||||
backdrop_path: req.tmdb.getImageURL(movie.backdrop_path, 'original')
|
||||
}));
|
||||
|
||||
res.json({
|
||||
...response.data,
|
||||
results: formattedResults
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching movies by category:', {
|
||||
message: error.message,
|
||||
response: error.response?.data
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch movies by category',
|
||||
details: error.response?.data?.status_message || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /categories/{id}/tv:
|
||||
* get:
|
||||
* summary: Получение сериалов по категории
|
||||
* description: Возвращает список сериалов, принадлежащих указанной категории (жанру)
|
||||
* tags: [categories]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* description: ID категории (жанра)
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Список сериалов по категории
|
||||
* 404:
|
||||
* description: Категория не найдена
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
*/
|
||||
router.get('/:id/tv', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { page = 1 } = req.query;
|
||||
|
||||
console.log(`Fetching TV shows for category (genre) ID: ${id}, page: ${page}`);
|
||||
|
||||
// Проверяем существование жанра в списке всех жанров
|
||||
const genresData = await req.tmdb.getAllGenres();
|
||||
const genreExists = genresData?.genres?.some(g => g.id === parseInt(id));
|
||||
|
||||
if (!genreExists) {
|
||||
return res.status(404).json({
|
||||
error: 'Category not found',
|
||||
details: `No category with ID ${id}`
|
||||
});
|
||||
}
|
||||
|
||||
// Получаем сериалы по жанру напрямую из TMDB
|
||||
console.log(`Making TMDB request for TV shows with genre ID: ${id}, page: ${page}`);
|
||||
|
||||
// В URL параметрах напрямую указываем жанр, чтобы быть уверенными
|
||||
const endpoint = `/discover/tv?with_genres=${id}`;
|
||||
|
||||
const requestParams = {
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
include_adult: false,
|
||||
include_null_first_air_dates: false,
|
||||
sort_by: 'popularity.desc'
|
||||
};
|
||||
|
||||
// Дополнительно добавляем вариации для разных жанров
|
||||
if (parseInt(id) % 2 === 0) {
|
||||
requestParams['vote_count.gte'] = 20;
|
||||
} else {
|
||||
requestParams['first_air_date.gte'] = '2010-01-01';
|
||||
}
|
||||
|
||||
console.log('TV Request params:', requestParams);
|
||||
console.log('TV Endpoint with genre:', endpoint);
|
||||
|
||||
const response = await req.tmdb.makeRequest('get', endpoint, {
|
||||
params: requestParams
|
||||
});
|
||||
|
||||
console.log(`TMDB response for TV genre ${id} received, status: ${response.status}, has results: ${!!response?.data?.results}`);
|
||||
if (response?.data?.results?.length > 0) {
|
||||
console.log(`First few TV show IDs: ${response.data.results.slice(0, 5).map(show => show.id).join(', ')}`);
|
||||
}
|
||||
|
||||
if (!response?.data?.results) {
|
||||
console.error('Invalid TV shows response:', response);
|
||||
return res.status(500).json({
|
||||
error: 'Invalid response from TMDB',
|
||||
details: 'TV shows data is missing'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('TV shows by category response:', {
|
||||
page: response.data.page,
|
||||
total_results: response.data.total_results,
|
||||
results_count: response.data.results?.length
|
||||
});
|
||||
|
||||
// Форматируем даты в результатах
|
||||
const formattedResults = response.data.results.map(tvShow => ({
|
||||
...tvShow,
|
||||
first_air_date: tvShow.first_air_date ? formatDate(tvShow.first_air_date) : undefined,
|
||||
poster_path: req.tmdb.getImageURL(tvShow.poster_path, 'w500'),
|
||||
backdrop_path: req.tmdb.getImageURL(tvShow.backdrop_path, 'original')
|
||||
}));
|
||||
|
||||
res.json({
|
||||
...response.data,
|
||||
results: formattedResults
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching TV shows by category:', {
|
||||
message: error.message,
|
||||
response: error.response?.data
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch TV shows by category',
|
||||
details: error.response?.data?.status_message || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,166 +0,0 @@
|
||||
const { Router } = require('express');
|
||||
const { getDb } = require('../db');
|
||||
const authRequired = require('../middleware/auth');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: favorites
|
||||
* description: Операции с избранным
|
||||
*/
|
||||
const router = Router();
|
||||
|
||||
// Apply auth middleware to all favorites routes
|
||||
router.use(authRequired);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /favorites:
|
||||
* get:
|
||||
* tags: [favorites]
|
||||
* summary: Получить список избранного пользователя
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const userId = req.user.email || req.user.id;
|
||||
const items = await db
|
||||
.collection('favorites')
|
||||
.find({ userId })
|
||||
.toArray();
|
||||
res.json(items);
|
||||
} catch (err) {
|
||||
console.error('Get favorites error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch favorites' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /favorites/check/{mediaId}:
|
||||
* get:
|
||||
* tags: [favorites]
|
||||
* summary: Проверить, находится ли элемент в избранном
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: mediaId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
router.get('/check/:mediaId', async (req, res) => {
|
||||
try {
|
||||
const { mediaId } = req.params;
|
||||
const db = await getDb();
|
||||
const exists = await db
|
||||
.collection('favorites')
|
||||
.findOne({ userId: req.user.email || req.user.id, mediaId });
|
||||
res.json({ exists: !!exists });
|
||||
} catch (err) {
|
||||
console.error('Check favorite error:', err);
|
||||
res.status(500).json({ error: 'Failed to check favorite' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /favorites/{mediaId}:
|
||||
* post:
|
||||
* tags: [favorites]
|
||||
* summary: Добавить элемент в избранное
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: mediaId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: mediaType
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [movie, tv]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* title:
|
||||
* type: string
|
||||
* posterPath:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
router.post('/:mediaId', async (req, res) => {
|
||||
try {
|
||||
const { mediaId } = req.params;
|
||||
const { mediaType } = req.query;
|
||||
const { title, posterPath } = req.body;
|
||||
if (!mediaType) return res.status(400).json({ error: 'mediaType required' });
|
||||
|
||||
const db = await getDb();
|
||||
await db.collection('favorites').insertOne({
|
||||
userId: req.user.email || req.user.id,
|
||||
mediaId,
|
||||
mediaType,
|
||||
title: title || '',
|
||||
posterPath: posterPath || '',
|
||||
createdAt: new Date()
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
if (err.code === 11000) {
|
||||
return res.status(409).json({ error: 'Already in favorites' });
|
||||
}
|
||||
console.error('Add favorite error:', err);
|
||||
res.status(500).json({ error: 'Failed to add favorite' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /favorites/{mediaId}:
|
||||
* delete:
|
||||
* tags: [favorites]
|
||||
* summary: Удалить элемент из избранного
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: mediaId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
router.delete('/:mediaId', async (req, res) => {
|
||||
try {
|
||||
const { mediaId } = req.params;
|
||||
const db = await getDb();
|
||||
await db.collection('favorites').deleteOne({ userId: req.user.email || req.user.id, mediaId });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Delete favorite error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete favorite' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,75 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const axios = require('axios');
|
||||
const path = require('path');
|
||||
|
||||
// Базовый URL для изображений TMDB
|
||||
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
|
||||
|
||||
// Путь к placeholder изображению
|
||||
const PLACEHOLDER_PATH = path.join(__dirname, '..', 'public', 'images', 'placeholder.jpg');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /images/{size}/{path}:
|
||||
* get:
|
||||
* summary: Прокси для изображений TMDB
|
||||
* description: Получает изображения с TMDB и отдает их клиенту
|
||||
* tags: [images]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: size
|
||||
* required: true
|
||||
* description: Размер изображения (w500, original и т.д.)
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: path
|
||||
* name: path
|
||||
* required: true
|
||||
* description: Путь к изображению
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Изображение
|
||||
* content:
|
||||
* image/*:
|
||||
* schema:
|
||||
* type: string
|
||||
* format: binary
|
||||
*/
|
||||
router.get('/:size/:path(*)', async (req, res) => {
|
||||
try {
|
||||
const { size, path: imagePath } = req.params;
|
||||
|
||||
// Если запрашивается placeholder, возвращаем локальный файл
|
||||
if (imagePath === 'placeholder.jpg') {
|
||||
return res.sendFile(PLACEHOLDER_PATH);
|
||||
}
|
||||
|
||||
// Проверяем размер изображения
|
||||
const validSizes = ['w92', 'w154', 'w185', 'w342', 'w500', 'w780', 'original'];
|
||||
const imageSize = validSizes.includes(size) ? size : 'original';
|
||||
|
||||
// Формируем URL изображения
|
||||
const imageUrl = `${TMDB_IMAGE_BASE_URL}/${imageSize}/${imagePath}`;
|
||||
|
||||
// Получаем изображение
|
||||
const response = await axios.get(imageUrl, {
|
||||
responseType: 'stream',
|
||||
validateStatus: status => status === 200
|
||||
});
|
||||
|
||||
// Устанавливаем заголовки
|
||||
res.set('Content-Type', response.headers['content-type']);
|
||||
res.set('Cache-Control', 'public, max-age=31536000'); // кэшируем на 1 год
|
||||
|
||||
// Передаем изображение клиенту
|
||||
response.data.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Image proxy error:', error.message);
|
||||
res.sendFile(PLACEHOLDER_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,732 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const { formatDate } = require('../utils/date');
|
||||
|
||||
// Helper to check if a title contains valid characters (Cyrillic, Latin, numbers, common punctuation)
|
||||
const isValidTitle = (title = '') => {
|
||||
if (!title) return true; // Allow items with no title (e.g., some TV episodes)
|
||||
// Regular expression to match titles containing Cyrillic, Latin, numbers, and common punctuation.
|
||||
const validTitleRegex = /^[\p{Script=Cyrillic}\p{Script=Latin}\d\s:!?'.,()-]+$/u;
|
||||
return validTitleRegex.test(title.trim());
|
||||
};
|
||||
|
||||
// Function to filter and format results
|
||||
const filterAndFormat = (results = []) => {
|
||||
if (!Array.isArray(results)) return [];
|
||||
return results
|
||||
.filter(item => {
|
||||
if (!item) return false;
|
||||
// Filter out items with a vote average of 0, unless they are upcoming (as they might not have votes yet)
|
||||
if (item.vote_average === 0 && !item.release_date) return false;
|
||||
// Filter based on title validity
|
||||
return isValidTitle(item.title || item.name || '');
|
||||
})
|
||||
.map(item => ({
|
||||
...item,
|
||||
release_date: item.release_date ? formatDate(item.release_date) : null,
|
||||
first_air_date: item.first_air_date ? formatDate(item.first_air_date) : null,
|
||||
}));
|
||||
};
|
||||
|
||||
// Middleware для логирования запросов
|
||||
router.use((req, res, next) => {
|
||||
console.log('Movies API Request:', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
query: req.query,
|
||||
params: req.params
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /movies/search:
|
||||
* get:
|
||||
* summary: Поиск фильмов
|
||||
* description: Поиск фильмов по запросу с поддержкой русского языка
|
||||
* tags: [movies]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: query
|
||||
* required: true
|
||||
* description: Поисковый запрос
|
||||
* schema:
|
||||
* type: string
|
||||
* example: Матрица
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы (по умолчанию 1)
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Успешный поиск
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* description: Текущая страница
|
||||
* total_pages:
|
||||
* type: integer
|
||||
* description: Всего страниц
|
||||
* total_results:
|
||||
* type: integer
|
||||
* description: Всего результатов
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Movie'
|
||||
* 400:
|
||||
* description: Неверный запрос
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { query, page = 1 } = req.query;
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: 'Query parameter is required' });
|
||||
}
|
||||
|
||||
console.log('Search request:', { query, page });
|
||||
|
||||
const data = await req.tmdb.searchMovies(query, page);
|
||||
|
||||
console.log('Search response:', {
|
||||
page: data.page,
|
||||
total_results: data.total_results,
|
||||
total_pages: data.total_pages,
|
||||
results_count: data.results?.length
|
||||
});
|
||||
|
||||
const formattedResults = filterAndFormat(data.results);
|
||||
res.json({ ...data, results: formattedResults });
|
||||
} catch (error) {
|
||||
console.error('Error searching movies:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /search/multi:
|
||||
* get:
|
||||
* summary: Мультипоиск
|
||||
* description: Поиск фильмов и сериалов по запросу
|
||||
* tags: [search]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: query
|
||||
* required: true
|
||||
* description: Поисковый запрос
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Успешный поиск
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* title:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* media_type:
|
||||
* type: string
|
||||
* enum: [movie, tv]
|
||||
*/
|
||||
router.get('/search/multi', async (req, res) => { // Путь должен быть /search/multi, а не /movies/search/multi, т.к. мы уже находимся в movies.js
|
||||
try {
|
||||
const { query, page = 1 } = req.query;
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: 'Query parameter is required' });
|
||||
}
|
||||
|
||||
console.log('Multi search request:', { query, page });
|
||||
|
||||
// Параллельный поиск фильмов и сериалов
|
||||
const [moviesData, tvData] = await Promise.all([
|
||||
req.tmdb.searchMovies(query, page),
|
||||
req.tmdb.searchTVShows(query, page)
|
||||
]);
|
||||
|
||||
// Объединяем и сортируем результаты по популярности
|
||||
const combinedResults = filterAndFormat([
|
||||
...moviesData.results.map(item => ({ ...item, media_type: 'movie' })),
|
||||
...tvData.results.map(item => ({ ...item, media_type: 'tv' }))
|
||||
]).sort((a, b) => b.popularity - a.popularity);
|
||||
|
||||
// Пагинация результатов
|
||||
const itemsPerPage = 20;
|
||||
const startIndex = (parseInt(page) - 1) * itemsPerPage;
|
||||
const paginatedResults = combinedResults.slice(startIndex, startIndex + itemsPerPage);
|
||||
|
||||
res.json({
|
||||
page: parseInt(page),
|
||||
results: paginatedResults,
|
||||
total_pages: Math.ceil(combinedResults.length / itemsPerPage),
|
||||
total_results: combinedResults.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in multi search:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /movies/popular:
|
||||
* get:
|
||||
* summary: Популярные фильмы
|
||||
* description: Получает список популярных фильмов с русскими названиями и описаниями
|
||||
* tags: [movies]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Список популярных фильмов
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Movie'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/popular', async (req, res) => {
|
||||
try {
|
||||
const { page } = req.query;
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
|
||||
console.log('Popular movies request:', {
|
||||
requestedPage: page,
|
||||
parsedPage: pageNum,
|
||||
rawQuery: req.query
|
||||
});
|
||||
|
||||
if (pageNum < 1) {
|
||||
return res.status(400).json({ error: 'Page must be greater than 0' });
|
||||
}
|
||||
|
||||
const movies = await req.tmdb.getPopularMovies(pageNum);
|
||||
|
||||
console.log('Popular movies response:', {
|
||||
requestedPage: pageNum,
|
||||
returnedPage: movies.page,
|
||||
totalPages: movies.total_pages,
|
||||
resultsCount: movies.results?.length
|
||||
});
|
||||
|
||||
if (!movies || !movies.results) {
|
||||
throw new Error('Invalid response from TMDB');
|
||||
}
|
||||
|
||||
const formattedResults = filterAndFormat(movies.results);
|
||||
|
||||
res.json({
|
||||
...movies,
|
||||
results: formattedResults
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Popular movies error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch popular movies',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /movies/top-rated:
|
||||
* get:
|
||||
* summary: Лучшие фильмы
|
||||
* description: Получает список лучших фильмов с русскими названиями и описаниями
|
||||
* tags: [movies]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Список лучших фильмов
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Movie'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/top-rated', async (req, res) => {
|
||||
try {
|
||||
const { page } = req.query;
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
|
||||
if (pageNum < 1) {
|
||||
return res.status(400).json({ error: 'Page must be greater than 0' });
|
||||
}
|
||||
|
||||
const movies = await req.tmdb.getTopRatedMovies(pageNum);
|
||||
|
||||
if (!movies || !movies.results) {
|
||||
throw new Error('Invalid response from TMDB');
|
||||
}
|
||||
|
||||
const formattedResults = filterAndFormat(movies.results);
|
||||
|
||||
res.json({
|
||||
...movies,
|
||||
results: formattedResults
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Top rated movies error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch top rated movies',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /movies/{id}:
|
||||
* get:
|
||||
* summary: Детали фильма
|
||||
* description: Получает подробную информацию о фильме по его ID
|
||||
* tags: [movies]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* description: ID фильма
|
||||
* schema:
|
||||
* type: integer
|
||||
* example: 550
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Детали фильма
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Movie'
|
||||
* 404:
|
||||
* description: Фильм не найден
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const movie = await req.tmdb.getMovie(id);
|
||||
|
||||
if (!movie) {
|
||||
return res.status(404).json({ error: 'Movie not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
...movie,
|
||||
release_date: formatDate(movie.release_date)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get movie error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch movie details',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /movies/{id}/external-ids:
|
||||
* get:
|
||||
* summary: Внешние ID фильма
|
||||
* description: Получает внешние идентификаторы фильма (IMDb, и т.д.)
|
||||
* tags: [movies]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* description: ID фильма
|
||||
* schema:
|
||||
* type: integer
|
||||
* example: 550
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Внешние ID фильма
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* imdb_id:
|
||||
* type: string
|
||||
* facebook_id:
|
||||
* type: string
|
||||
* instagram_id:
|
||||
* type: string
|
||||
* twitter_id:
|
||||
* type: string
|
||||
* 404:
|
||||
* description: Фильм не найден
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/:id/external-ids', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const externalIds = await req.tmdb.getMovieExternalIDs(id);
|
||||
|
||||
if (!externalIds) {
|
||||
return res.status(404).json({ error: 'External IDs not found' });
|
||||
}
|
||||
|
||||
res.json(externalIds);
|
||||
} catch (error) {
|
||||
console.error('Get external IDs error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch external IDs',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /movies/upcoming:
|
||||
* get:
|
||||
* summary: Предстоящие фильмы
|
||||
* description: Получает список предстоящих фильмов с русскими названиями и описаниями
|
||||
* tags: [movies]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Список предстоящих фильмов
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Movie'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/upcoming', async (req, res) => {
|
||||
try {
|
||||
const { page } = req.query;
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
|
||||
if (pageNum < 1) {
|
||||
return res.status(400).json({ error: 'Page must be greater than 0' });
|
||||
}
|
||||
|
||||
const movies = await req.tmdb.getUpcomingMovies(pageNum);
|
||||
|
||||
if (!movies || !movies.results) {
|
||||
throw new Error('Invalid response from TMDB');
|
||||
}
|
||||
|
||||
const formattedResults = filterAndFormat(movies.results);
|
||||
|
||||
res.json({
|
||||
...movies,
|
||||
results: formattedResults
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upcoming movies error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch upcoming movies',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /movies/{id}/videos:
|
||||
* get:
|
||||
* summary: Видео фильма
|
||||
* description: Получает список видео для фильма (трейлеры, тизеры и т.д.)
|
||||
* tags: [movies]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* description: ID фильма
|
||||
* schema:
|
||||
* type: integer
|
||||
* example: 550
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Список видео
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* key:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* site:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* 404:
|
||||
* description: Видео не найдены
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/:id/videos', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const videos = await req.tmdb.getMovieVideos(id);
|
||||
|
||||
if (!videos || !videos.results) {
|
||||
return res.status(404).json({ error: 'Videos not found' });
|
||||
}
|
||||
|
||||
res.json(videos);
|
||||
} catch (error) {
|
||||
console.error('Get videos error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch videos',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /movies/genres:
|
||||
* get:
|
||||
* summary: Получение списка жанров
|
||||
* description: Возвращает список всех доступных жанров фильмов
|
||||
* tags: [movies]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Список жанров
|
||||
*/
|
||||
router.get('/genres', async (req, res) => {
|
||||
try {
|
||||
console.log('Fetching genres...');
|
||||
const response = await req.tmdb.getGenres();
|
||||
|
||||
if (!response?.data?.genres) {
|
||||
console.error('Invalid genres response:', response);
|
||||
return res.status(500).json({
|
||||
error: 'Invalid response from TMDB',
|
||||
details: 'Genres data is missing'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Genres response:', {
|
||||
count: response.data.genres.length,
|
||||
genres: response.data.genres
|
||||
});
|
||||
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching genres:', {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch genres',
|
||||
details: error.response?.data?.status_message || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /movies/discover:
|
||||
* get:
|
||||
* summary: Получение фильмов по жанру
|
||||
* description: Возвращает список фильмов определенного жанра
|
||||
* tags: [movies]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: with_genres
|
||||
* required: true
|
||||
* description: ID жанра
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
*/
|
||||
router.get('/discover', async (req, res) => {
|
||||
try {
|
||||
const { with_genres, page = 1 } = req.query;
|
||||
|
||||
if (!with_genres) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required parameter',
|
||||
details: 'Genre ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Fetching movies by genre:', { with_genres, page });
|
||||
|
||||
const response = await req.tmdb.makeRequest('get', '/discover/movie', {
|
||||
params: {
|
||||
with_genres,
|
||||
page,
|
||||
language: 'ru-RU',
|
||||
'vote_count.gte': 100,
|
||||
'vote_average.gte': 1,
|
||||
sort_by: 'popularity.desc',
|
||||
include_adult: false
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Movies by genre response:', {
|
||||
page: response.data.page,
|
||||
total_results: response.data.total_results,
|
||||
results_count: response.data.results?.length
|
||||
});
|
||||
|
||||
// Форматируем даты в результатах
|
||||
const formattedResults = response.data.results.map(movie => ({
|
||||
...movie,
|
||||
release_date: movie.release_date ? formatDate(movie.release_date) : undefined
|
||||
}));
|
||||
|
||||
res.json({
|
||||
...response.data,
|
||||
results: formattedResults
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching movies by genre:', error.response?.data || error.message);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch movies by genre',
|
||||
details: error.response?.data?.status_message || error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,110 +0,0 @@
|
||||
const { Router } = require('express');
|
||||
const fetch = require('node-fetch');
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* tags:
|
||||
* name: players
|
||||
* description: Плееры Alloha и Lumex
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /players/alloha:
|
||||
* get:
|
||||
* tags: [players]
|
||||
* summary: Получить iframe от Alloha по IMDb ID или TMDB ID
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: imdb_id
|
||||
* schema:
|
||||
* type: string
|
||||
* description: IMDb ID (например tt0111161)
|
||||
* - in: query
|
||||
* name: tmdb_id
|
||||
* schema:
|
||||
* type: string
|
||||
* description: TMDB ID (числовой)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
router.get('/alloha', async (req, res) => {
|
||||
try {
|
||||
const { imdb_id: imdbId, tmdb_id: tmdbId } = req.query;
|
||||
if (!imdbId && !tmdbId) {
|
||||
return res.status(400).json({ error: 'imdb_id or tmdb_id query param is required' });
|
||||
}
|
||||
|
||||
const token = process.env.ALLOHA_TOKEN;
|
||||
if (!token) {
|
||||
return res.status(500).json({ error: 'Server misconfiguration: ALLOHA_TOKEN missing' });
|
||||
}
|
||||
|
||||
const idParam = imdbId ? `imdb=${encodeURIComponent(imdbId)}` : `tmdb=${encodeURIComponent(tmdbId)}`;
|
||||
const apiUrl = `https://api.alloha.tv/?token=${token}&${idParam}`;
|
||||
const apiRes = await fetch(apiUrl);
|
||||
|
||||
if (!apiRes.ok) {
|
||||
console.error('Alloha response error', apiRes.status);
|
||||
return res.status(apiRes.status).json({ error: 'Failed to fetch from Alloha' });
|
||||
}
|
||||
|
||||
const json = await apiRes.json();
|
||||
if (json.status !== 'success' || !json.data?.iframe) {
|
||||
return res.status(404).json({ error: 'Video not found' });
|
||||
}
|
||||
|
||||
let iframeCode = json.data.iframe;
|
||||
// If Alloha returns just a URL, wrap it in an iframe
|
||||
if (!iframeCode.includes('<')) {
|
||||
iframeCode = `<iframe src="${iframeCode}" allowfullscreen style="border:none;width:100%;height:100%"></iframe>`;
|
||||
}
|
||||
|
||||
// If iframe markup already provided
|
||||
const htmlDoc = `<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Alloha Player</title><style>html,body{margin:0;height:100%;}</style></head><body>${iframeCode}</body></html>`;
|
||||
res.set('Content-Type', 'text/html');
|
||||
return res.send(htmlDoc);
|
||||
} catch (e) {
|
||||
console.error('Alloha route error:', e);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /players/lumex:
|
||||
* get:
|
||||
* tags: [players]
|
||||
* summary: Получить URL плеера Lumex
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: imdb_id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
router.get('/lumex', (req, res) => {
|
||||
try {
|
||||
const { imdb_id: imdbId } = req.query;
|
||||
if (!imdbId) return res.status(400).json({ error: 'imdb_id required' });
|
||||
|
||||
const baseUrl = process.env.LUMEX_URL || process.env.NEXT_PUBLIC_LUMEX_URL;
|
||||
if (!baseUrl) return res.status(500).json({ error: 'Server misconfiguration: LUMEX_URL missing' });
|
||||
|
||||
const url = `${baseUrl}?imdb_id=${encodeURIComponent(imdbId)}`;
|
||||
const iframe = `<iframe src=\"${url}\" allowfullscreen loading=\"lazy\" style=\"border:none;width:100%;height:100%;\"></iframe>`;
|
||||
const htmlDoc = `<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Lumex Player</title><style>html,body{margin:0;height:100%;}</style></head><body>${iframe}</body></html>`;
|
||||
res.set('Content-Type', 'text/html');
|
||||
res.send(htmlDoc);
|
||||
} catch (e) {
|
||||
console.error('Lumex route error:', e);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,116 +0,0 @@
|
||||
const { Router } = require('express');
|
||||
const { getDb } = require('../db');
|
||||
const authRequired = require('../middleware/auth');
|
||||
|
||||
const fetch = global.fetch || require('node-fetch');
|
||||
|
||||
const router = Router();
|
||||
|
||||
const CUB_API_URL = 'https://cub.rip/api';
|
||||
const VALID_REACTIONS = ['fire', 'nice', 'think', 'bore', 'shit'];
|
||||
|
||||
// [PUBLIC] Получить все счетчики реакций для медиа
|
||||
router.get('/:mediaType/:mediaId/counts', async (req, res) => {
|
||||
try {
|
||||
const { mediaType, mediaId } = req.params;
|
||||
const cubId = `${mediaType}_${mediaId}`;
|
||||
const response = await fetch(`${CUB_API_URL}/reactions/get/${cubId}`);
|
||||
if (!response.ok) {
|
||||
// Возвращаем пустой объект, если на CUB.RIP еще нет реакций
|
||||
return res.json({});
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
const counts = (data.result || []).reduce((acc, reaction) => {
|
||||
acc[reaction.type] = reaction.counter;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
res.json(counts);
|
||||
} catch (err) {
|
||||
console.error('Get reaction counts error:', err);
|
||||
res.status(500).json({ error: 'Failed to get reaction counts' });
|
||||
}
|
||||
});
|
||||
|
||||
// [AUTH] Получить реакцию текущего пользователя для медиа
|
||||
router.get('/:mediaType/:mediaId/my-reaction', authRequired, async (req, res) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { mediaType, mediaId } = req.params;
|
||||
const userId = req.user.id;
|
||||
const fullMediaId = `${mediaType}_${mediaId}`;
|
||||
|
||||
const reaction = await db.collection('reactions').findOne({ userId, mediaId: fullMediaId });
|
||||
res.json(reaction);
|
||||
} catch (err) {
|
||||
console.error('Get user reaction error:', err);
|
||||
res.status(500).json({ error: 'Failed to get user reaction' });
|
||||
}
|
||||
});
|
||||
|
||||
// [AUTH] Добавить, обновить или удалить реакцию
|
||||
router.post('/', authRequired, async (req, res) => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { mediaId, type } = req.body; // mediaId здесь это fullMediaId, например "movie_12345"
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!mediaId || !type) {
|
||||
return res.status(400).json({ error: 'mediaId and type are required' });
|
||||
}
|
||||
|
||||
if (!VALID_REACTIONS.includes(type)) {
|
||||
return res.status(400).json({ error: 'Invalid reaction type' });
|
||||
}
|
||||
|
||||
const existingReaction = await db.collection('reactions').findOne({ userId, mediaId });
|
||||
|
||||
if (existingReaction) {
|
||||
// Если тип реакции тот же, удаляем ее (отмена реакции)
|
||||
if (existingReaction.type === type) {
|
||||
// Отправляем запрос на удаление в CUB API
|
||||
await fetch(`${CUB_API_URL}/reactions/remove/${mediaId}/${type}`);
|
||||
await db.collection('reactions').deleteOne({ _id: existingReaction._id });
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
// Если тип другой, обновляем его
|
||||
// Атомарно выполняем операции с CUB API и базой данных
|
||||
await Promise.all([
|
||||
// 1. Удаляем старую реакцию из CUB API
|
||||
fetch(`${CUB_API_URL}/reactions/remove/${mediaId}/${existingReaction.type}`),
|
||||
// 2. Добавляем новую реакцию в CUB API
|
||||
fetch(`${CUB_API_URL}/reactions/add/${mediaId}/${type}`),
|
||||
// 3. Обновляем реакцию в нашей базе данных
|
||||
db.collection('reactions').updateOne(
|
||||
{ _id: existingReaction._id },
|
||||
{ $set: { type, createdAt: new Date() } }
|
||||
)
|
||||
]);
|
||||
|
||||
const updatedReaction = await db.collection('reactions').findOne({ _id: existingReaction._id });
|
||||
return res.json(updatedReaction);
|
||||
}
|
||||
} else {
|
||||
// Если реакции не было, создаем новую
|
||||
const mediaType = mediaId.split('_')[0]; // Извлекаем 'movie' или 'tv'
|
||||
const newReaction = {
|
||||
userId,
|
||||
mediaId, // full mediaId, e.g., 'movie_12345'
|
||||
mediaType,
|
||||
type,
|
||||
createdAt: new Date()
|
||||
};
|
||||
const result = await db.collection('reactions').insertOne(newReaction);
|
||||
// Отправляем запрос в CUB API
|
||||
await fetch(`${CUB_API_URL}/reactions/add/${mediaId}/${type}`);
|
||||
const insertedDoc = await db.collection('reactions').findOne({ _id: result.insertedId });
|
||||
return res.status(201).json(insertedDoc);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Set reaction error:', err);
|
||||
res.status(500).json({ error: 'Failed to set reaction' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,476 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const TorrentService = require('../services/torrent.service');
|
||||
|
||||
// Создаем экземпляр сервиса
|
||||
const torrentService = new TorrentService();
|
||||
|
||||
// Middleware для логирования запросов
|
||||
router.use((req, res, next) => {
|
||||
console.log('Torrents API Request:', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
query: req.query,
|
||||
params: req.params
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /torrents/search/{imdbId}:
|
||||
* get:
|
||||
* summary: Поиск торрентов по IMDB ID
|
||||
* description: Поиск торрентов для фильма или сериала по его IMDB ID через bitru.org
|
||||
* tags: [torrents]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: imdbId
|
||||
* required: true
|
||||
* description: IMDB ID фильма/сериала (например, tt1234567)
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: type
|
||||
* required: false
|
||||
* description: Тип контента (movie или tv)
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [movie, tv]
|
||||
* default: movie
|
||||
* - in: query
|
||||
* name: quality
|
||||
* required: false
|
||||
* description: Желаемое качество (например, 1080p, 4K). Можно указать несколько.
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: minQuality
|
||||
* required: false
|
||||
* description: Минимальное качество.
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: ['360p', '480p', '720p', '1080p', '1440p', '2160p']
|
||||
* - in: query
|
||||
* name: maxQuality
|
||||
* required: false
|
||||
* description: Максимальное качество.
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: ['360p', '480p', '720p', '1080p', '1440p', '2160p']
|
||||
* - in: query
|
||||
* name: excludeQualities
|
||||
* required: false
|
||||
* description: Исключить качества. Можно указать несколько.
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: hdr
|
||||
* required: false
|
||||
* description: Фильтр по наличию HDR.
|
||||
* schema:
|
||||
* type: boolean
|
||||
* - in: query
|
||||
* name: hevc
|
||||
* required: false
|
||||
* description: Фильтр по наличию HEVC/H.265.
|
||||
* schema:
|
||||
* type: boolean
|
||||
* - in: query
|
||||
* name: sortBy
|
||||
* required: false
|
||||
* description: Поле для сортировки.
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [seeders, size, date]
|
||||
* default: seeders
|
||||
* - in: query
|
||||
* name: sortOrder
|
||||
* required: false
|
||||
* description: Порядок сортировки.
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [asc, desc]
|
||||
* default: desc
|
||||
* - in: query
|
||||
* name: groupByQuality
|
||||
* required: false
|
||||
* description: Группировать результаты по качеству.
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* - in: query
|
||||
* name: season
|
||||
* required: false
|
||||
* description: Номер сезона для сериалов.
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* - in: query
|
||||
* name: groupBySeason
|
||||
* required: false
|
||||
* description: Группировать результаты по сезону (только для сериалов).
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Успешный ответ с результатами поиска.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* imdbId:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* total:
|
||||
* type: integer
|
||||
* grouped:
|
||||
* type: boolean
|
||||
* results:
|
||||
* oneOf:
|
||||
* - type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Torrent'
|
||||
* - type: object
|
||||
* properties:
|
||||
* '4K':
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Torrent'
|
||||
* '1080p':
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Torrent'
|
||||
* '720p':
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Torrent'
|
||||
* 400:
|
||||
* description: Неверный запрос
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* error:
|
||||
* type: string
|
||||
* description: Описание ошибки
|
||||
* 404:
|
||||
* description: Контент не найден
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
*/
|
||||
router.get('/search/:imdbId', async (req, res) => {
|
||||
try {
|
||||
const { imdbId } = req.params;
|
||||
const {
|
||||
type = 'movie',
|
||||
quality,
|
||||
minQuality,
|
||||
maxQuality,
|
||||
excludeQualities,
|
||||
hdr,
|
||||
hevc,
|
||||
sortBy = 'seeders',
|
||||
sortOrder = 'desc',
|
||||
groupByQuality = false,
|
||||
season,
|
||||
groupBySeason = false
|
||||
} = req.query;
|
||||
|
||||
// Валидация IMDB ID
|
||||
if (!imdbId || !imdbId.match(/^tt\d+$/)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid IMDB ID format. Expected format: tt1234567'
|
||||
});
|
||||
}
|
||||
|
||||
// Валидация типа контента
|
||||
if (!['movie', 'tv'].includes(type)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid type. Must be "movie" or "tv"'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Torrent search request:', { imdbId, type, quality, season, groupByQuality, groupBySeason });
|
||||
|
||||
// Поиск торрентов с учетом сезона для сериалов
|
||||
const searchOptions = { season: season ? parseInt(season) : null };
|
||||
const results = await torrentService.searchTorrentsByImdbId(req.tmdb, imdbId, type, searchOptions);
|
||||
console.log(`Found ${results.length} torrents for IMDB ID: ${imdbId}`);
|
||||
|
||||
// Если результатов нет, возвращаем 404
|
||||
if (results.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'No torrents found for this IMDB ID',
|
||||
imdbId,
|
||||
type
|
||||
});
|
||||
}
|
||||
|
||||
// Применяем фильтрацию по качеству, если указаны параметры
|
||||
let filteredResults = results;
|
||||
const qualityFilter = {};
|
||||
|
||||
if (quality) {
|
||||
qualityFilter.qualities = Array.isArray(quality) ? quality : [quality];
|
||||
}
|
||||
if (minQuality) qualityFilter.minQuality = minQuality;
|
||||
if (maxQuality) qualityFilter.maxQuality = maxQuality;
|
||||
if (excludeQualities) {
|
||||
qualityFilter.excludeQualities = Array.isArray(excludeQualities) ? excludeQualities : [excludeQualities];
|
||||
}
|
||||
if (hdr !== undefined) qualityFilter.hdr = hdr === 'true';
|
||||
if (hevc !== undefined) qualityFilter.hevc = hevc === 'true';
|
||||
|
||||
// Применяем фильтрацию, если есть параметры качества
|
||||
if (Object.keys(qualityFilter).length > 0) {
|
||||
const redApiClient = torrentService.redApiClient;
|
||||
filteredResults = redApiClient.filterByQuality(results, qualityFilter);
|
||||
console.log(`Filtered to ${filteredResults.length} torrents by quality`);
|
||||
}
|
||||
|
||||
// Дополнительная фильтрация по сезону для сериалов
|
||||
if (type === 'tv' && season) {
|
||||
const redApiClient = torrentService.redApiClient;
|
||||
filteredResults = redApiClient.filterBySeason(filteredResults, parseInt(season));
|
||||
console.log(`Filtered to ${filteredResults.length} torrents for season ${season}`);
|
||||
}
|
||||
|
||||
// Группировка или обычная сортировка
|
||||
let responseData;
|
||||
const redApiClient = torrentService.redApiClient;
|
||||
|
||||
if (groupBySeason === 'true' || groupBySeason === true) {
|
||||
// Группируем по сезону (только для сериалов)
|
||||
if (type === 'tv') {
|
||||
const groupedResults = redApiClient.groupBySeason(filteredResults);
|
||||
responseData = {
|
||||
imdbId,
|
||||
type,
|
||||
total: filteredResults.length,
|
||||
grouped: true,
|
||||
groupedBy: 'season',
|
||||
results: groupedResults
|
||||
};
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
error: 'Season grouping is only available for TV series (type=tv)'
|
||||
});
|
||||
}
|
||||
} else if (groupByQuality === 'true' || groupByQuality === true) {
|
||||
// Группируем по качеству
|
||||
const groupedResults = redApiClient.groupByQuality(filteredResults);
|
||||
|
||||
responseData = {
|
||||
imdbId,
|
||||
type,
|
||||
total: filteredResults.length,
|
||||
grouped: true,
|
||||
groupedBy: 'quality',
|
||||
results: groupedResults
|
||||
};
|
||||
} else {
|
||||
// Обычная сортировка
|
||||
const redApiClient = torrentService.redApiClient;
|
||||
const sortedResults = redApiClient.sortTorrents(filteredResults, sortBy, sortOrder);
|
||||
|
||||
responseData = {
|
||||
imdbId,
|
||||
type,
|
||||
total: filteredResults.length,
|
||||
grouped: false,
|
||||
season: season ? parseInt(season) : null,
|
||||
results: sortedResults
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Torrent search response:', {
|
||||
imdbId,
|
||||
type,
|
||||
results_count: filteredResults.length,
|
||||
grouped: responseData.grouped
|
||||
});
|
||||
|
||||
res.json(responseData);
|
||||
} catch (error) {
|
||||
console.error('Error searching torrents:', error);
|
||||
|
||||
// Проверяем, является ли это ошибкой "не найдено"
|
||||
if (error.message.includes('not found')) {
|
||||
return res.status(404).json({
|
||||
error: 'Movie/TV show not found',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to search torrents',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /torrents/search:
|
||||
* get:
|
||||
* summary: Поиск торрентов по названию
|
||||
* description: Прямой поиск торрентов по названию на bitru.org
|
||||
* tags: [torrents]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: query
|
||||
* required: true
|
||||
* description: Поисковый запрос
|
||||
* schema:
|
||||
* type: string
|
||||
* example: Матрица
|
||||
* - in: query
|
||||
* name: category
|
||||
* description: Категория поиска (1 - фильмы, 2 - сериалы)
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: ['1', '2']
|
||||
* default: '1'
|
||||
* example: '1'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Результаты поиска
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* query:
|
||||
* type: string
|
||||
* description: Поисковый запрос
|
||||
* category:
|
||||
* type: string
|
||||
* description: Категория поиска
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* url:
|
||||
* type: string
|
||||
* size:
|
||||
* type: string
|
||||
* seeders:
|
||||
* type: integer
|
||||
* leechers:
|
||||
* type: integer
|
||||
* source:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Неверный запрос
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
*/
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { query, category = '1' } = req.query;
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({
|
||||
error: 'Query parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!['1', '2'].includes(category)) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid category. Must be "1" (movies) or "2" (tv shows)'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Direct torrent search request:', { query, category });
|
||||
|
||||
const results = await torrentService.searchTorrents(query, category);
|
||||
|
||||
console.log('Direct torrent search response:', {
|
||||
query,
|
||||
category,
|
||||
results_count: results.length
|
||||
});
|
||||
|
||||
res.json({
|
||||
query,
|
||||
category,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in direct torrent search:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to search torrents',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /torrents/health:
|
||||
* get:
|
||||
* summary: Проверка работоспособности торрент-сервиса
|
||||
* description: Проверяет доступность bitru.org
|
||||
* tags: [torrents]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Сервис работает
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: ok
|
||||
* timestamp:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* source:
|
||||
* type: string
|
||||
* example: bitru.org
|
||||
* 500:
|
||||
* description: Сервис недоступен
|
||||
*/
|
||||
router.get('/health', async (req, res) => {
|
||||
try {
|
||||
const axios = require('axios');
|
||||
|
||||
// Проверяем доступность bitru.org
|
||||
const response = await axios.get('https://bitru.org', {
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'bitru.org',
|
||||
statusCode: response.status
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'bitru.org',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
310
src/routes/tv.js
310
src/routes/tv.js
@@ -1,310 +0,0 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { formatDate } = require('../utils/date');
|
||||
|
||||
// Middleware для логирования запросов
|
||||
router.use((req, res, next) => {
|
||||
console.log('TV Shows API Request:', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
query: req.query,
|
||||
params: req.params
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tv/popular:
|
||||
* get:
|
||||
* summary: Популярные сериалы
|
||||
* description: Получает список популярных сериалов с русскими названиями и описаниями
|
||||
* tags: [tv]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Список популярных сериалов
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/TVShow'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/popular', async (req, res) => {
|
||||
try {
|
||||
const { page } = req.query;
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
|
||||
if (pageNum < 1) {
|
||||
return res.status(400).json({ error: 'Page must be greater than 0' });
|
||||
}
|
||||
|
||||
const response = await req.tmdb.getPopularTVShows(pageNum);
|
||||
|
||||
if (!response || !response.results) {
|
||||
throw new Error('Invalid response from TMDB');
|
||||
}
|
||||
|
||||
const formattedResults = response.results.map(show => ({
|
||||
...show,
|
||||
first_air_date: formatDate(show.first_air_date)
|
||||
}));
|
||||
|
||||
res.json({
|
||||
...response,
|
||||
results: formattedResults
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Popular TV shows error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch popular TV shows',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tv/search:
|
||||
* get:
|
||||
* summary: Поиск сериалов
|
||||
* description: Поиск сериалов по запросу с поддержкой русского языка
|
||||
* tags: [tv]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: query
|
||||
* required: true
|
||||
* description: Поисковый запрос
|
||||
* schema:
|
||||
* type: string
|
||||
* example: Игра престолов
|
||||
* - in: query
|
||||
* name: page
|
||||
* description: Номер страницы (по умолчанию 1)
|
||||
* schema:
|
||||
* type: integer
|
||||
* minimum: 1
|
||||
* default: 1
|
||||
* example: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Успешный поиск
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* results:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/TVShow'
|
||||
* 400:
|
||||
* description: Неверный запрос
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { query, page } = req.query;
|
||||
const pageNum = parseInt(page, 10) || 1;
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({ error: 'Query parameter is required' });
|
||||
}
|
||||
|
||||
if (pageNum < 1) {
|
||||
return res.status(400).json({ error: 'Page must be greater than 0' });
|
||||
}
|
||||
|
||||
const response = await req.tmdb.searchTVShows(query, pageNum);
|
||||
|
||||
if (!response || !response.results) {
|
||||
throw new Error('Failed to fetch data from TMDB');
|
||||
}
|
||||
|
||||
const formattedResults = response.results.map(show => ({
|
||||
...show,
|
||||
first_air_date: formatDate(show.first_air_date)
|
||||
}));
|
||||
|
||||
res.json({
|
||||
...response,
|
||||
results: formattedResults
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search TV shows error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to search TV shows',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tv/{id}:
|
||||
* get:
|
||||
* summary: Детали сериала
|
||||
* description: Получает подробную информацию о сериале по его ID
|
||||
* tags: [tv]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* description: ID сериала
|
||||
* schema:
|
||||
* type: integer
|
||||
* example: 1399
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Детали сериала
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TVShow'
|
||||
* 404:
|
||||
* description: Сериал не найден
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const show = await req.tmdb.getTVShow(id);
|
||||
|
||||
if (!show) {
|
||||
return res.status(404).json({ error: 'TV show not found' });
|
||||
}
|
||||
|
||||
// Ensure all required fields are present and formatted correctly
|
||||
const formattedShow = {
|
||||
id: show.id,
|
||||
name: show.name,
|
||||
overview: show.overview,
|
||||
poster_path: show.poster_path,
|
||||
backdrop_path: show.backdrop_path,
|
||||
first_air_date: formatDate(show.first_air_date),
|
||||
vote_average: show.vote_average,
|
||||
vote_count: show.vote_count,
|
||||
number_of_seasons: show.number_of_seasons,
|
||||
number_of_episodes: show.number_of_episodes,
|
||||
genres: show.genres || [],
|
||||
genre_ids: show.genre_ids || show.genres?.map(g => g.id) || [],
|
||||
credits: show.credits || { cast: [], crew: [] },
|
||||
videos: show.videos || { results: [] }
|
||||
};
|
||||
|
||||
res.json(formattedShow);
|
||||
} catch (error) {
|
||||
console.error('Get TV show error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch TV show details',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /tv/{id}/external-ids:
|
||||
* get:
|
||||
* summary: Внешние ID сериала
|
||||
* description: Получает внешние идентификаторы сериала (IMDb, и т.д.)
|
||||
* tags: [tv]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* description: ID сериала
|
||||
* schema:
|
||||
* type: integer
|
||||
* example: 1399
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Внешние ID сериала
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* imdb_id:
|
||||
* type: string
|
||||
* tvdb_id:
|
||||
* type: integer
|
||||
* facebook_id:
|
||||
* type: string
|
||||
* instagram_id:
|
||||
* type: string
|
||||
* twitter_id:
|
||||
* type: string
|
||||
* 404:
|
||||
* description: Сериал не найден
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 500:
|
||||
* description: Ошибка сервера
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
router.get('/:id/external-ids', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const externalIds = await req.tmdb.getTVShowExternalIDs(id);
|
||||
|
||||
if (!externalIds) {
|
||||
return res.status(404).json({ error: 'External IDs not found' });
|
||||
}
|
||||
|
||||
res.json(externalIds);
|
||||
} catch (error) {
|
||||
console.error('Get external IDs error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch external IDs',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,768 +0,0 @@
|
||||
const axios = require('axios');
|
||||
|
||||
/**
|
||||
* Клиент для работы с RedAPI (Lampac)
|
||||
* Основан на коде Lampac ApiController.cs и RedApi.cs
|
||||
*/
|
||||
class RedApiClient {
|
||||
constructor(baseUrl = 'http://redapi.cfhttp.top', apikey = '') {
|
||||
this.baseUrl = baseUrl;
|
||||
this.apikey = apikey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск торрентов через RedAPI с поддержкой сезонов
|
||||
* @param {Object} params - параметры поиска
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchTorrents(params) {
|
||||
const {
|
||||
query, // поисковый запрос
|
||||
title, // название фильма/сериала
|
||||
title_original, // оригинальное название
|
||||
year, // год выпуска
|
||||
is_serial, // тип контента: 1-фильм, 2-сериал, 5-аниме
|
||||
category, // категория
|
||||
imdb, // IMDB ID
|
||||
season // номер сезона для сериалов
|
||||
} = params;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (query) searchParams.append('query', query);
|
||||
if (title) searchParams.append('title', title);
|
||||
if (title_original) searchParams.append('title_original', title_original);
|
||||
if (year) searchParams.append('year', year);
|
||||
if (is_serial) searchParams.append('is_serial', is_serial);
|
||||
if (category) searchParams.append('category[]', category);
|
||||
if (imdb) searchParams.append('imdb', imdb);
|
||||
if (season) searchParams.append('season', season);
|
||||
if (this.apikey) searchParams.append('apikey', this.apikey);
|
||||
|
||||
try {
|
||||
console.log('RedAPI search params:', params);
|
||||
console.log('Search URL:', `${this.baseUrl}/api/v2.0/indexers/all/results?${searchParams}`);
|
||||
|
||||
const response = await axios.get(
|
||||
`${this.baseUrl}/api/v2.0/indexers/all/results?${searchParams}`,
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
|
||||
return this.parseResults(response.data);
|
||||
} catch (error) {
|
||||
console.error('RedAPI search error:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг результатов поиска с поддержкой сезонов
|
||||
* @param {Object} data - ответ от RedAPI
|
||||
* @returns {Array}
|
||||
*/
|
||||
parseResults(data) {
|
||||
if (!data.Results || !Array.isArray(data.Results)) {
|
||||
console.log('RedAPI: No results found or invalid response format');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`RedAPI: Found ${data.Results.length} results`);
|
||||
|
||||
return data.Results.map(torrent => ({
|
||||
title: torrent.Title,
|
||||
tracker: torrent.Tracker,
|
||||
size: torrent.Size,
|
||||
seeders: torrent.Seeders,
|
||||
peers: torrent.Peers,
|
||||
magnet: torrent.MagnetUri,
|
||||
publishDate: torrent.PublishDate,
|
||||
category: torrent.CategoryDesc,
|
||||
quality: torrent.Info?.quality,
|
||||
voice: torrent.Info?.voices,
|
||||
details: torrent.Details,
|
||||
types: torrent.Info?.types,
|
||||
seasons: torrent.Info?.seasons,
|
||||
source: 'RedAPI'
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтрация результатов по типу контента на клиенте
|
||||
* Решает проблему смешанных результатов от API
|
||||
* @param {Array} results - результаты поиска
|
||||
* @param {string} contentType - тип контента (movie/serial/anime)
|
||||
* @returns {Array}
|
||||
*/
|
||||
filterByContentType(results, contentType) {
|
||||
return results.filter(torrent => {
|
||||
// Фильтрация по полю types, если оно есть
|
||||
if (torrent.types && Array.isArray(torrent.types)) {
|
||||
switch (contentType) {
|
||||
case 'movie':
|
||||
return torrent.types.some(type =>
|
||||
['movie', 'multfilm', 'documovie'].includes(type)
|
||||
);
|
||||
case 'serial':
|
||||
return torrent.types.some(type =>
|
||||
['serial', 'multserial', 'docuserial', 'tvshow'].includes(type)
|
||||
);
|
||||
case 'anime':
|
||||
return torrent.types.includes('anime');
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по названию, если types недоступно
|
||||
const title = torrent.title.toLowerCase();
|
||||
switch (contentType) {
|
||||
case 'movie':
|
||||
return !/(сезон|серии|series|season|эпизод)/i.test(title);
|
||||
case 'serial':
|
||||
return /(сезон|серии|series|season|эпизод)/i.test(title);
|
||||
case 'anime':
|
||||
return torrent.category === 'TV/Anime' || /anime/i.test(title);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск фильмов с дополнительной фильтрацией
|
||||
* @param {string} title - название на русском
|
||||
* @param {string} originalTitle - оригинальное название
|
||||
* @param {number} year - год выпуска
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchMovies(title, originalTitle, year) {
|
||||
const results = await this.searchTorrents({
|
||||
title,
|
||||
title_original: originalTitle,
|
||||
year,
|
||||
is_serial: 1,
|
||||
category: '2000'
|
||||
});
|
||||
|
||||
return this.filterByContentType(results, 'movie');
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск сериалов с дополнительной фильтрацией
|
||||
* @param {string} title - название на русском
|
||||
* @param {string} originalTitle - оригинальное название
|
||||
* @param {number} year - год выпуска
|
||||
* @param {number} season - номер сезона (опционально)
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchSeries(title, originalTitle, year, season = null) {
|
||||
const searchParams = {
|
||||
title,
|
||||
title_original: originalTitle,
|
||||
year,
|
||||
is_serial: 2,
|
||||
category: '5000'
|
||||
};
|
||||
|
||||
// Добавляем параметр season если он указан
|
||||
if (season) {
|
||||
searchParams.season = season;
|
||||
}
|
||||
|
||||
const results = await this.searchTorrents(searchParams);
|
||||
|
||||
return this.filterByContentType(results, 'serial');
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск аниме
|
||||
* @param {string} title - название на русском
|
||||
* @param {string} originalTitle - оригинальное название
|
||||
* @param {number} year - год выпуска
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchAnime(title, originalTitle, year) {
|
||||
const results = await this.searchTorrents({
|
||||
title,
|
||||
title_original: originalTitle,
|
||||
year,
|
||||
is_serial: 5,
|
||||
category: '5070'
|
||||
});
|
||||
|
||||
return this.filterByContentType(results, 'anime');
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск по общему запросу с фильтрацией качества
|
||||
* @param {string} query - поисковый запрос
|
||||
* @param {string} type - тип контента (movie/serial/anime)
|
||||
* @param {number} year - год выпуска
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchByQuery(query, type = 'movie', year = null) {
|
||||
const params = { query };
|
||||
|
||||
if (year) params.year = year;
|
||||
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
params.is_serial = 1;
|
||||
params.category = '2000';
|
||||
break;
|
||||
case 'serial':
|
||||
params.is_serial = 2;
|
||||
params.category = '5000';
|
||||
break;
|
||||
case 'anime':
|
||||
params.is_serial = 5;
|
||||
params.category = '5070';
|
||||
break;
|
||||
}
|
||||
|
||||
const results = await this.searchTorrents(params);
|
||||
return this.filterByContentType(results, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение информации о фильме по IMDB ID через Alloha API
|
||||
* @param {string} imdbId - IMDB ID
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async getMovieInfoByImdb(imdbId) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&imdb=${imdbId}`,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
const data = response.data?.data;
|
||||
return data ? {
|
||||
name: data.name,
|
||||
original_name: data.original_name
|
||||
} : null;
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения информации по IMDB:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение информации по Kinopoisk ID
|
||||
* @param {string} kpId - Kinopoisk ID
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async getMovieInfoByKinopoisk(kpId) {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&kp=${kpId}`, //Данный токен для alloha является открытым(взят из Lampac)
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
const data = response.data?.data;
|
||||
return data ? {
|
||||
name: data.name,
|
||||
original_name: data.original_name
|
||||
} : null;
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения информации по Kinopoisk ID:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск по IMDB ID
|
||||
* @param {string} imdbId - IMDB ID (например, 'tt1234567')
|
||||
* @param {string} type - 'movie', 'serial' или 'anime'
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchByImdb(imdbId, type = 'movie') {
|
||||
if (!imdbId || !imdbId.match(/^tt\d+$/)) {
|
||||
throw new Error('Неверный формат IMDB ID. Должен быть в формате tt1234567');
|
||||
}
|
||||
|
||||
console.log(`RedAPI search by IMDB ID: ${imdbId}`);
|
||||
|
||||
// Сначала получаем информацию о фильме
|
||||
const movieInfo = await this.getMovieInfoByImdb(imdbId);
|
||||
|
||||
const params = { imdb: imdbId };
|
||||
|
||||
// Устанавливаем категорию и тип контента
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
params.is_serial = 1;
|
||||
params.category = '2000';
|
||||
break;
|
||||
case 'serial':
|
||||
params.is_serial = 2;
|
||||
params.category = '5000';
|
||||
break;
|
||||
case 'anime':
|
||||
params.is_serial = 5;
|
||||
params.category = '5070';
|
||||
break;
|
||||
default:
|
||||
params.is_serial = 1;
|
||||
params.category = '2000';
|
||||
}
|
||||
|
||||
// Если получили информацию о фильме, добавляем названия
|
||||
if (movieInfo) {
|
||||
params.title = movieInfo.name;
|
||||
params.title_original = movieInfo.original_name;
|
||||
}
|
||||
|
||||
const results = await this.searchTorrents(params);
|
||||
return this.filterByContentType(results, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск по Kinopoisk ID
|
||||
* @param {string} kpId - Kinopoisk ID
|
||||
* @param {string} type - 'movie', 'serial' или 'anime'
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchByKinopoisk(kpId, type = 'movie') {
|
||||
if (!kpId || !kpId.toString().match(/^\d+$/)) {
|
||||
throw new Error('Неверный формат Kinopoisk ID');
|
||||
}
|
||||
|
||||
console.log(`RedAPI search by Kinopoisk ID: ${kpId}`);
|
||||
|
||||
const movieInfo = await this.getMovieInfoByKinopoisk(kpId);
|
||||
|
||||
const params = { query: `kp${kpId}` };
|
||||
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
params.is_serial = 1;
|
||||
params.category = '2000';
|
||||
break;
|
||||
case 'serial':
|
||||
params.is_serial = 2;
|
||||
params.category = '5000';
|
||||
break;
|
||||
case 'anime':
|
||||
params.is_serial = 5;
|
||||
params.category = '5070';
|
||||
break;
|
||||
}
|
||||
|
||||
if (movieInfo) {
|
||||
params.title = movieInfo.name;
|
||||
params.title_original = movieInfo.original_name;
|
||||
}
|
||||
|
||||
const results = await this.searchTorrents(params);
|
||||
return this.filterByContentType(results, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Расширенная фильтрация по качеству
|
||||
* @param {Array} results - результаты поиска
|
||||
* @param {Object} qualityFilter - объект с параметрами фильтрации качества
|
||||
* @returns {Array}
|
||||
*/
|
||||
filterByQuality(results, qualityFilter = {}) {
|
||||
if (!qualityFilter || Object.keys(qualityFilter).length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const {
|
||||
qualities = [], // ['1080p', '720p', '4K', '2160p']
|
||||
minQuality = null, // минимальное качество
|
||||
maxQuality = null, // максимальное качество
|
||||
excludeQualities = [], // исключить качества
|
||||
hdr = null, // true/false для HDR
|
||||
hevc = null // true/false для HEVC/H.265
|
||||
} = qualityFilter;
|
||||
|
||||
// Порядок качества от низкого к высокому
|
||||
const qualityOrder = ['360p', '480p', '720p', '1080p', '1440p', '2160p', '4K'];
|
||||
|
||||
return results.filter(torrent => {
|
||||
const title = torrent.title.toLowerCase();
|
||||
|
||||
// Определяем качество из названия
|
||||
const detectedQuality = this.detectQuality(title);
|
||||
|
||||
// Фильтрация по конкретным качествам
|
||||
if (qualities.length > 0) {
|
||||
const hasQuality = qualities.some(q =>
|
||||
title.includes(q.toLowerCase()) ||
|
||||
(q === '4K' && title.includes('2160p'))
|
||||
);
|
||||
if (!hasQuality) return false;
|
||||
}
|
||||
|
||||
// Фильтрация по минимальному качеству
|
||||
if (minQuality && detectedQuality) {
|
||||
const minIndex = qualityOrder.indexOf(minQuality);
|
||||
const currentIndex = qualityOrder.indexOf(detectedQuality);
|
||||
if (currentIndex !== -1 && minIndex !== -1 && currentIndex < minIndex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по максимальному качеству
|
||||
if (maxQuality && detectedQuality) {
|
||||
const maxIndex = qualityOrder.indexOf(maxQuality);
|
||||
const currentIndex = qualityOrder.indexOf(detectedQuality);
|
||||
if (currentIndex !== -1 && maxIndex !== -1 && currentIndex > maxIndex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Исключение определенных качеств
|
||||
if (excludeQualities.length > 0) {
|
||||
const hasExcluded = excludeQualities.some(q =>
|
||||
title.includes(q.toLowerCase())
|
||||
);
|
||||
if (hasExcluded) return false;
|
||||
}
|
||||
|
||||
// Фильтрация по HDR
|
||||
if (hdr !== null) {
|
||||
const hasHDR = /hdr|dolby.vision|dv/i.test(title);
|
||||
if (hdr && !hasHDR) return false;
|
||||
if (!hdr && hasHDR) return false;
|
||||
}
|
||||
|
||||
// Фильтрация по HEVC
|
||||
if (hevc !== null) {
|
||||
const hasHEVC = /hevc|h\.265|x265/i.test(title);
|
||||
if (hevc && !hasHEVC) return false;
|
||||
if (!hevc && hasHEVC) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Определение качества из названия торрента
|
||||
* @param {string} title - название торрента
|
||||
* @returns {string|null}
|
||||
*/
|
||||
detectQuality(title) {
|
||||
const qualityPatterns = [
|
||||
{ pattern: /2160p|4k/i, quality: '2160p' },
|
||||
{ pattern: /1440p/i, quality: '1440p' },
|
||||
{ pattern: /1080p/i, quality: '1080p' },
|
||||
{ pattern: /720p/i, quality: '720p' },
|
||||
{ pattern: /480p/i, quality: '480p' },
|
||||
{ pattern: /360p/i, quality: '360p' }
|
||||
];
|
||||
|
||||
for (const { pattern, quality } of qualityPatterns) {
|
||||
if (pattern.test(title)) {
|
||||
return quality;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение статистики по качеству
|
||||
* @param {Array} results - результаты поиска
|
||||
* @returns {Object}
|
||||
*/
|
||||
getQualityStats(results) {
|
||||
const stats = {};
|
||||
|
||||
results.forEach(torrent => {
|
||||
const quality = this.detectQuality(torrent.title.toLowerCase());
|
||||
if (quality) {
|
||||
stats[quality] = (stats[quality] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Группировка результатов по качеству
|
||||
* @param {Array} results - результаты поиска
|
||||
* @returns {Object} - объект с группами качества
|
||||
*/
|
||||
groupByQuality(results) {
|
||||
const groups = {
|
||||
'4K': [],
|
||||
'2160p': [],
|
||||
'1440p': [],
|
||||
'1080p': [],
|
||||
'720p': [],
|
||||
'480p': [],
|
||||
'360p': [],
|
||||
'unknown': []
|
||||
};
|
||||
|
||||
results.forEach(torrent => {
|
||||
const quality = this.detectQuality(torrent.title.toLowerCase());
|
||||
|
||||
if (quality) {
|
||||
// Объединяем 4K и 2160p в одну группу
|
||||
if (quality === '2160p') {
|
||||
groups['4K'].push(torrent);
|
||||
} else {
|
||||
groups[quality].push(torrent);
|
||||
}
|
||||
} else {
|
||||
groups['unknown'].push(torrent);
|
||||
}
|
||||
});
|
||||
|
||||
// Удаляем пустые группы и сортируем по качеству (от высокого к низкому)
|
||||
const sortedGroups = {};
|
||||
const qualityOrder = ['4K', '1440p', '1080p', '720p', '480p', '360p', 'unknown'];
|
||||
|
||||
qualityOrder.forEach(quality => {
|
||||
if (groups[quality].length > 0) {
|
||||
// Сортируем торренты внутри группы по сидам
|
||||
groups[quality].sort((a, b) => (b.seeders || 0) - (a.seeders || 0));
|
||||
sortedGroups[quality] = groups[quality];
|
||||
}
|
||||
});
|
||||
|
||||
return sortedGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Расширенный поиск с поддержкой сезонов
|
||||
* @param {Object} searchParams - параметры поиска
|
||||
* @param {Object} qualityFilter - фильтр качества
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchWithQualityFilter(searchParams, qualityFilter = {}) {
|
||||
const results = await this.searchTorrents(searchParams);
|
||||
|
||||
// Применяем фильтрацию по типу контента
|
||||
let filteredResults = results;
|
||||
if (searchParams.contentType) {
|
||||
filteredResults = this.filterByContentType(results, searchParams.contentType);
|
||||
}
|
||||
|
||||
// Применяем фильтрацию по сезону (дополнительная на клиенте)
|
||||
if (searchParams.season && !searchParams.seasonFromAPI) {
|
||||
filteredResults = this.filterBySeason(filteredResults, searchParams.season);
|
||||
}
|
||||
|
||||
// Применяем фильтрацию по качеству
|
||||
filteredResults = this.filterByQuality(filteredResults, qualityFilter);
|
||||
|
||||
// Сортируем результаты
|
||||
if (qualityFilter.sortBy) {
|
||||
filteredResults = this.sortTorrents(filteredResults, qualityFilter.sortBy, qualityFilter.sortOrder);
|
||||
}
|
||||
|
||||
return filteredResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сортировка результатов
|
||||
* @param {Array} results - результаты поиска
|
||||
* @param {string} sortBy - поле для сортировки (seeders/size/date)
|
||||
* @param {string} order - порядок сортировки (asc/desc)
|
||||
* @returns {Array}
|
||||
*/
|
||||
sortTorrents(results, sortBy = 'seeders', order = 'desc') {
|
||||
return results.sort((a, b) => {
|
||||
let valueA, valueB;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'seeders':
|
||||
valueA = a.seeders || 0;
|
||||
valueB = b.seeders || 0;
|
||||
break;
|
||||
case 'size':
|
||||
valueA = a.size || 0;
|
||||
valueB = b.size || 0;
|
||||
break;
|
||||
case 'date':
|
||||
valueA = new Date(a.publishDate || 0);
|
||||
valueB = new Date(b.publishDate || 0);
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (order === 'asc') {
|
||||
return valueA - valueB;
|
||||
} else {
|
||||
return valueB - valueA;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск сериалов с поддержкой выбора сезона
|
||||
* @param {string} title - название на русском
|
||||
* @param {string} originalTitle - оригинальное название
|
||||
* @param {number} year - год выпуска
|
||||
* @param {number} season - номер сезона (опционально)
|
||||
* @param {Object} qualityFilter - фильтр качества
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchSeries(title, originalTitle, year, season = null, qualityFilter = {}) {
|
||||
const params = {
|
||||
title,
|
||||
title_original: originalTitle,
|
||||
year,
|
||||
is_serial: 2,
|
||||
category: '5000',
|
||||
contentType: 'serial'
|
||||
};
|
||||
|
||||
if (season) {
|
||||
params.season = season;
|
||||
}
|
||||
|
||||
return this.searchWithQualityFilter(params, qualityFilter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение доступных сезонов для сериала
|
||||
* @param {string} title - название сериала
|
||||
* @param {string} originalTitle - оригинальное название
|
||||
* @param {number} year - год выпуска
|
||||
* @returns {Promise<Array>} - массив номеров сезонов
|
||||
*/
|
||||
async getAvailableSeasons(title, originalTitle, year) {
|
||||
const results = await this.searchSeries(title, originalTitle, year);
|
||||
const seasons = new Set();
|
||||
|
||||
results.forEach(torrent => {
|
||||
// Extract from the dedicated field
|
||||
if (torrent.seasons && Array.isArray(torrent.seasons)) {
|
||||
torrent.seasons.forEach(s => seasons.add(parseInt(s)));
|
||||
}
|
||||
|
||||
// Extract from title
|
||||
const title = torrent.title;
|
||||
const seasonRegex = /(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон/gi;
|
||||
for (const match of title.matchAll(seasonRegex)) {
|
||||
const seasonNumber = parseInt(match[1] || match[2]);
|
||||
if (!isNaN(seasonNumber)) {
|
||||
seasons.add(seasonNumber);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(seasons).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Фильтрация результатов по сезону на клиенте
|
||||
* Показываем только те торренты, где в названии найден номер сезона
|
||||
* @param {Array} results - результаты поиска
|
||||
* @param {number} season - номер сезона
|
||||
* @returns {Array}
|
||||
*/
|
||||
filterBySeason(results, season) {
|
||||
if (!season) return results;
|
||||
|
||||
return results.filter(torrent => {
|
||||
// Используем точную регулярку для поиска сезона в названии
|
||||
const title = torrent.title;
|
||||
const seasonRegex = /(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон/gi;
|
||||
|
||||
// Проверяем, есть ли в названии нужный сезон
|
||||
let foundSeason = false;
|
||||
for (const match of title.matchAll(seasonRegex)) {
|
||||
const seasonNumber = parseInt(match[1] || match[2]);
|
||||
if (!isNaN(seasonNumber) && seasonNumber === season) {
|
||||
foundSeason = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return foundSeason;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск конкретного сезона сериала
|
||||
* @param {string} title - название сериала
|
||||
* @param {string} originalTitle - оригинальное название
|
||||
* @param {number} year - год выпуска
|
||||
* @param {number} season - номер сезона
|
||||
* @param {Object} qualityFilter - фильтр качества
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchSeriesSeason(title, originalTitle, year, season, qualityFilter = {}) {
|
||||
// Сначала пробуем поиск с параметром season
|
||||
let results = await this.searchSeries(title, originalTitle, year, season, qualityFilter);
|
||||
|
||||
// Если результатов мало, делаем общий поиск и фильтруем на клиенте
|
||||
if (results.length < 5) {
|
||||
const allResults = await this.searchSeries(title, originalTitle, year, null, qualityFilter);
|
||||
const filteredResults = this.filterBySeason(allResults, season);
|
||||
|
||||
// Объединяем результаты и убираем дубликаты
|
||||
const combined = [...results, ...filteredResults];
|
||||
const unique = combined.filter((torrent, index, self) =>
|
||||
index === self.findIndex(t => t.magnet === torrent.magnet)
|
||||
);
|
||||
|
||||
results = unique;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Группировка результатов по сезону
|
||||
* @param {Array} results - результаты поиска
|
||||
* @returns {Object} - объект с группами по сезонам
|
||||
*/
|
||||
groupBySeason(results) {
|
||||
const grouped = {};
|
||||
|
||||
results.forEach(torrent => {
|
||||
const seasons = new Set();
|
||||
|
||||
// Extract seasons from the dedicated field
|
||||
if (torrent.seasons && Array.isArray(torrent.seasons)) {
|
||||
torrent.seasons.forEach(s => seasons.add(parseInt(s)));
|
||||
}
|
||||
|
||||
// Extract from title as a fallback or supplement
|
||||
const title = torrent.title;
|
||||
const seasonRegex = /(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон/gi;
|
||||
for (const match of title.matchAll(seasonRegex)) {
|
||||
const seasonNumber = parseInt(match[1] || match[2]);
|
||||
if (!isNaN(seasonNumber)) {
|
||||
seasons.add(seasonNumber);
|
||||
}
|
||||
}
|
||||
|
||||
const seasonsArray = Array.from(seasons);
|
||||
|
||||
// If no season is found, group as 'unknown'
|
||||
if (seasonsArray.length === 0) {
|
||||
seasonsArray.push('unknown');
|
||||
}
|
||||
|
||||
// Add torrent to all relevant season groups
|
||||
seasonsArray.forEach(season => {
|
||||
const seasonKey = season === 'unknown' ? 'Неизвестно' : `Сезон ${season}`;
|
||||
if (!grouped[seasonKey]) {
|
||||
grouped[seasonKey] = [];
|
||||
}
|
||||
// Ensure torrent is not added to the same group twice
|
||||
if (!grouped[seasonKey].find(t => t.magnet === torrent.magnet)) {
|
||||
grouped[seasonKey].push(torrent);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort torrents within each group by seeders
|
||||
Object.keys(grouped).forEach(season => {
|
||||
grouped[season].sort((a, b) => (b.seeders || 0) - (a.seeders || 0));
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RedApiClient;
|
||||
@@ -1,129 +0,0 @@
|
||||
const RedApiClient = require('./redapi.service');
|
||||
|
||||
class TorrentService {
|
||||
constructor() {
|
||||
this.redApiClient = new RedApiClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить название фильма/сериала по IMDB ID
|
||||
* @param {object} tmdbClient - TMDB клиент
|
||||
* @param {string} imdbId - IMDB ID (например, 'tt1234567')
|
||||
* @param {string} type - 'movie' или 'tv'
|
||||
* @returns {Promise<{originalTitle: string, russianTitle: string, year: string}|null>}
|
||||
*/
|
||||
async getTitleByImdbId(tmdbClient, imdbId, type) {
|
||||
try {
|
||||
const tmdbType = (type === 'serial' || type === 'tv') ? 'tv' : 'movie';
|
||||
|
||||
const response = await tmdbClient.makeRequest('GET', `/find/${imdbId}`, {
|
||||
params: {
|
||||
external_source: 'imdb_id',
|
||||
language: 'ru-RU'
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
const results = tmdbType === 'movie' ? data.movie_results : data.tv_results;
|
||||
|
||||
if (results && results.length > 0) {
|
||||
const item = results[0];
|
||||
const tmdbId = item.id;
|
||||
|
||||
// Получаем детали для оригинального названия
|
||||
const detailsResponse = await tmdbClient.makeRequest('GET',
|
||||
tmdbType === 'movie' ? `/movie/${tmdbId}` : `/tv/${tmdbId}`,
|
||||
{
|
||||
params: {
|
||||
language: 'en-US' // Получаем оригинальное название
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const details = detailsResponse.data;
|
||||
const originalTitle = tmdbType === 'movie'
|
||||
? details.original_title || details.title
|
||||
: details.original_name || details.name;
|
||||
|
||||
const russianTitle = tmdbType === 'movie'
|
||||
? item.title || item.original_title
|
||||
: item.name || item.original_name;
|
||||
|
||||
return {
|
||||
originalTitle: originalTitle,
|
||||
russianTitle: russianTitle,
|
||||
year: (item.release_date || item.first_air_date)?.split('-')[0]
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error getting title by IMDB ID: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск торрентов по IMDB ID через RedAPI с поддержкой сезонов
|
||||
* @param {object} tmdbClient - TMDB клиент
|
||||
* @param {string} imdbId - IMDB ID (tt1234567)
|
||||
* @param {string} type - 'movie' или 'tv'
|
||||
* @param {Object} options - дополнительные опции (например, season)
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async searchTorrentsByImdbId(tmdbClient, imdbId, type = 'movie', options = {}) {
|
||||
try {
|
||||
console.log(`Starting RedAPI torrent search for IMDB ID: ${imdbId}, type: ${type}, season: ${options.season || 'all'}`);
|
||||
|
||||
const movieInfo = await this.getTitleByImdbId(tmdbClient, imdbId, type);
|
||||
if (!movieInfo) {
|
||||
console.log('No movie info found for IMDB ID:', imdbId);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('Movie info found:', movieInfo);
|
||||
|
||||
let results = [];
|
||||
if (type === 'movie') {
|
||||
results = await this.redApiClient.searchMovies(
|
||||
movieInfo.russianTitle,
|
||||
movieInfo.originalTitle,
|
||||
movieInfo.year
|
||||
);
|
||||
} else {
|
||||
// Для сериалов используем метод с поддержкой сезонов
|
||||
if (options.season) {
|
||||
results = await this.redApiClient.searchSeriesSeason(
|
||||
movieInfo.russianTitle,
|
||||
movieInfo.originalTitle,
|
||||
movieInfo.year,
|
||||
options.season
|
||||
);
|
||||
} else {
|
||||
results = await this.redApiClient.searchSeries(
|
||||
movieInfo.russianTitle,
|
||||
movieInfo.originalTitle,
|
||||
movieInfo.year
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log('No results found by titles, trying query search...');
|
||||
const query = movieInfo.originalTitle || movieInfo.russianTitle;
|
||||
let searchQuery = `${query} ${movieInfo.year}`;
|
||||
if (options.season && type === 'tv') {
|
||||
searchQuery += ` season ${options.season}`;
|
||||
}
|
||||
results = await this.redApiClient.searchByQuery(searchQuery, type, movieInfo.year);
|
||||
}
|
||||
|
||||
console.log(`Found ${results.length} torrent results via RedAPI`);
|
||||
return results.slice(0, 20);
|
||||
} catch (e) {
|
||||
console.error('Error searching torrents by IMDB ID:', e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TorrentService;
|
||||
@@ -1,23 +0,0 @@
|
||||
const { getDb } = require('../db');
|
||||
|
||||
// Delete unverified users older than 7 days
|
||||
async function deleteStaleUsers() {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const result = await db.collection('users').deleteMany({ verified: false, createdAt: { $lt: weekAgo } });
|
||||
if (result.deletedCount) {
|
||||
console.log(`Cleanup: removed ${result.deletedCount} stale unverified users`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Cleanup error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// run once at startup and then every 24h
|
||||
(async () => {
|
||||
await deleteStaleUsers();
|
||||
setInterval(deleteStaleUsers, 24 * 60 * 60 * 1000);
|
||||
})();
|
||||
|
||||
module.exports = { deleteStaleUsers };
|
||||
@@ -1,13 +0,0 @@
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return null;
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatDate
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
const os = require('os');
|
||||
const process = require('process');
|
||||
|
||||
class HealthCheck {
|
||||
constructor() {
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
getUptime() {
|
||||
return Math.floor((Date.now() - this.startTime) / 1000);
|
||||
}
|
||||
|
||||
getMemoryUsage() {
|
||||
const used = process.memoryUsage();
|
||||
return {
|
||||
heapTotal: Math.round(used.heapTotal / 1024 / 1024), // MB
|
||||
heapUsed: Math.round(used.heapUsed / 1024 / 1024), // MB
|
||||
rss: Math.round(used.rss / 1024 / 1024), // MB
|
||||
memoryUsage: Math.round((used.heapUsed / used.heapTotal) * 100) // %
|
||||
};
|
||||
}
|
||||
|
||||
getSystemInfo() {
|
||||
return {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
nodeVersion: process.version,
|
||||
cpuUsage: Math.round(os.loadavg()[0] * 100) / 100,
|
||||
totalMemory: Math.round(os.totalmem() / 1024 / 1024), // MB
|
||||
freeMemory: Math.round(os.freemem() / 1024 / 1024) // MB
|
||||
};
|
||||
}
|
||||
|
||||
async checkTMDBConnection(tmdbClient) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
await tmdbClient.makeRequest('GET', '/configuration');
|
||||
const endTime = Date.now();
|
||||
return {
|
||||
status: 'ok',
|
||||
responseTime: endTime - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
formatUptime(seconds) {
|
||||
const days = Math.floor(seconds / (24 * 60 * 60));
|
||||
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60));
|
||||
const minutes = Math.floor((seconds % (60 * 60)) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
const parts = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (remainingSeconds > 0 || parts.length === 0) parts.push(`${remainingSeconds}s`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
async getFullHealth(tmdbClient) {
|
||||
const uptime = this.getUptime();
|
||||
const tmdbStatus = await this.checkTMDBConnection(tmdbClient);
|
||||
const memory = this.getMemoryUsage();
|
||||
const system = this.getSystemInfo();
|
||||
|
||||
return {
|
||||
status: tmdbStatus.status === 'ok' ? 'healthy' : 'unhealthy',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
uptime: {
|
||||
seconds: uptime,
|
||||
formatted: this.formatUptime(uptime)
|
||||
},
|
||||
tmdb: {
|
||||
status: tmdbStatus.status,
|
||||
responseTime: tmdbStatus.responseTime,
|
||||
error: tmdbStatus.error
|
||||
},
|
||||
memory: {
|
||||
...memory,
|
||||
system: {
|
||||
total: system.totalMemory,
|
||||
free: system.freeMemory,
|
||||
usage: Math.round(((system.totalMemory - system.freeMemory) / system.totalMemory) * 100)
|
||||
}
|
||||
},
|
||||
system: {
|
||||
platform: system.platform,
|
||||
arch: system.arch,
|
||||
nodeVersion: system.nodeVersion,
|
||||
cpuUsage: system.cpuUsage
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new HealthCheck();
|
||||
@@ -1,45 +0,0 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: process.env.GMAIL_USER || process.env.gmail_user,
|
||||
pass: process.env.GMAIL_APP_PASSWORD || process.env.gmail_app_password
|
||||
}
|
||||
});
|
||||
|
||||
async function sendVerificationEmail(to, code) {
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: process.env.GMAIL_USER || process.env.gmail_user,
|
||||
to,
|
||||
subject: 'Подтверждение регистрации Neo Movies',
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #2196f3;">Neo Movies</h1>
|
||||
<p>Здравствуйте!</p>
|
||||
<p>Для завершения регистрации введите этот код:</p>
|
||||
<div style="
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
letter-spacing: 4px;
|
||||
margin: 20px 0;
|
||||
">
|
||||
${code}
|
||||
</div>
|
||||
<p>Код действителен в течение 10 минут.</p>
|
||||
<p>Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо.</p>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error sending verification email:', err);
|
||||
return { error: 'Failed to send email' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendVerificationEmail };
|
||||
11
vercel.json
11
vercel.json
@@ -2,17 +2,20 @@
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{
|
||||
"src": "api/index.js",
|
||||
"use": "@vercel/node"
|
||||
"src": "api/index.go",
|
||||
"use": "@vercel/go",
|
||||
"config": {
|
||||
"maxDuration": 10
|
||||
}
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "/api/index.js"
|
||||
"dest": "/api/index.go"
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"NODE_ENV": "production"
|
||||
"GO_VERSION": "1.21"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user