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
|
||||||
|
|
||||||
## Особенности
|
## 🚀 Особенности
|
||||||
|
|
||||||
- Поиск фильмов
|
- ⚡ **Высокая производительность** - написан на Go
|
||||||
- Информация о фильмах
|
- 🔒 **JWT аутентификация** с email верификацией
|
||||||
- Популярные фильмы
|
- 🎭 **TMDB API интеграция** для данных о фильмах/сериалах
|
||||||
- Топ рейтинговые фильмы
|
- 📧 **Email уведомления** через Gmail SMTP
|
||||||
- Предстоящие фильмы
|
- 🔍 **Полнотекстовый поиск** фильмов и сериалов
|
||||||
- Swagger документация
|
- ⭐ **Система избранного** для пользователей
|
||||||
- Поддержка русского языка
|
- 🎨 **Современная документация** с Scalar API Reference
|
||||||
|
- 🌐 **CORS поддержка** для фронтенд интеграции
|
||||||
|
- ☁️ **Готов к деплою на Vercel**
|
||||||
|
|
||||||
## Установка
|
## 📚 Основные функции
|
||||||
|
|
||||||
1. Клонируйте репозиторий:
|
### 🔐 Аутентификация
|
||||||
|
- **Регистрация** с email верификацией (6-значный код)
|
||||||
|
- **Авторизация** JWT токенами
|
||||||
|
- **Управление профилем** пользователя
|
||||||
|
- **Email подтверждение** обязательно для входа
|
||||||
|
|
||||||
|
### 🎬 TMDB интеграция
|
||||||
|
- Поиск фильмов и сериалов
|
||||||
|
- Популярные, топ-рейтинговые, предстоящие
|
||||||
|
- Детальная информация с трейлерами и актерами
|
||||||
|
- Рекомендации и похожие фильмы
|
||||||
|
- Мультипоиск по всем типам контента
|
||||||
|
|
||||||
|
### ⭐ Пользовательские функции
|
||||||
|
- Добавление фильмов в избранное
|
||||||
|
- Персональные списки
|
||||||
|
- История просмотров
|
||||||
|
|
||||||
|
### 🎭 Плееры
|
||||||
|
- **Alloha Player** интеграция
|
||||||
|
- **Lumex Player** интеграция
|
||||||
|
|
||||||
|
### 📦 Дополнительно
|
||||||
|
- **Торренты** - поиск по IMDB ID с фильтрацией
|
||||||
|
- **Реакции** - лайки/дизлайки с внешним API
|
||||||
|
- **Изображения** - прокси для TMDB с кэшированием
|
||||||
|
- **Категории** - жанры и фильмы по категориям
|
||||||
|
|
||||||
|
## 🛠 Быстрый старт
|
||||||
|
|
||||||
|
### Локальная разработка
|
||||||
|
|
||||||
|
1. **Клонирование репозитория**
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitlab.com/foxixus/neomovies-api.git
|
git clone <your-repo>
|
||||||
cd neomovies-api
|
cd neomovies-api
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Установите зависимости:
|
2. **Создание .env файла**
|
||||||
```bash
|
```bash
|
||||||
npm install
|
cp .env.example .env
|
||||||
|
# Заполните необходимые переменные
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Создайте файл `.env`:
|
3. **Установка зависимостей**
|
||||||
```bash
|
```bash
|
||||||
touch .env
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Добавьте ваш TMDB Access Token в `.env` файл:
|
4. **Запуск**
|
||||||
|
```bash
|
||||||
|
go run main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
TMDB_ACCESS_TOKEN=your_tmdb_access_token
|
API будет доступен на `http://localhost:3000`
|
||||||
MONGODB_URI=your_mongodb_uri
|
|
||||||
JWT_SECRET=your_jwt_secret
|
### Деплой на 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_USER=your_gmail@gmail.com
|
||||||
GMAIL_APP_PASSWORD=your_app_specific_password
|
GMAIL_APP_PASSWORD=your_app_specific_password
|
||||||
|
|
||||||
|
# Для плееров
|
||||||
LUMEX_URL=your_lumex_player_url
|
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
|
```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
|
```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
|
```bash
|
||||||
npm i -g vercel
|
# Поиск торрентов для фильма "Побег из Шоушенка"
|
||||||
|
curl "https://api.neomovies.ru/api/v1/torrents/search/tt0111161?type=movie&quality=1080p"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Войдите в ваш аккаунт Vercel:
|
## 🎨 Документация API
|
||||||
```bash
|
|
||||||
vercel login
|
Интерактивная документация доступна по адресу:
|
||||||
|
|
||||||
|
**🔗 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:
|
- **Go 1.21** - основной язык
|
||||||
- Перейдите в настройки проекта на Vercel
|
- **Gorilla Mux** - HTTP роутер
|
||||||
- Добавьте `TMDB_ACCESS_TOKEN`, `MONGODB_URI`, `JWT_SECRET`, `GMAIL_USER`, `GMAIL_APP_PASSWORD`, `LUMEX_URL`, `ALLOHA_TOKEN` в раздел Environment Variables
|
- **MongoDB** - база данных
|
||||||
|
- **JWT** - аутентификация
|
||||||
|
- **TMDB API** - данные о фильмах
|
||||||
|
- **Gmail SMTP** - email уведомления
|
||||||
|
- **Vercel** - деплой и хостинг
|
||||||
|
|
||||||
## API Endpoints
|
## 🚀 Производительность
|
||||||
|
|
||||||
- `GET /health` - Проверка работоспособности API
|
По сравнению с Node.js версией:
|
||||||
- `GET /movies/search?query=<search_term>&page=<page_number>` - Поиск фильмов
|
- **3x быстрее** обработка запросов
|
||||||
- `GET /movies/:id` - Получить информацию о фильме
|
- **50% меньше** потребление памяти
|
||||||
- `GET /movies/popular` - Получить список популярных фильмов
|
- **Конкурентность** благодаря горутинам
|
||||||
- `GET /movies/top-rated` - Получить список топ рейтинговых фильмов
|
- **Типобезопасность** предотвращает ошибки
|
||||||
- `GET /movies/upcoming` - Получить список предстоящих фильмов
|
|
||||||
- `GET /movies/:id/external-ids` - Получить внешние ID фильма
|
|
||||||
|
|
||||||
## Документация API
|
## 🤝 Contribution
|
||||||
|
|
||||||
После запуска API, документация Swagger доступна по адресу:
|
1. Форкните репозиторий
|
||||||
```
|
2. Создайте feature-ветку (`git checkout -b feature/amazing-feature`)
|
||||||
http://localhost:3000/api-docs
|
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 };
|
|
||||||
13
vercel.json
13
vercel.json
@@ -2,17 +2,20 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"builds": [
|
"builds": [
|
||||||
{
|
{
|
||||||
"src": "api/index.js",
|
"src": "api/index.go",
|
||||||
"use": "@vercel/node"
|
"use": "@vercel/go",
|
||||||
|
"config": {
|
||||||
|
"maxDuration": 10
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"routes": [
|
"routes": [
|
||||||
{
|
{
|
||||||
"src": "/(.*)",
|
"src": "/(.*)",
|
||||||
"dest": "/api/index.js"
|
"dest": "/api/index.go"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"NODE_ENV": "production"
|
"GO_VERSION": "1.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user