Rewrite api to Go

This commit is contained in:
2025-08-07 13:47:42 +00:00
parent 8c47b81289
commit 8131c7db8c
56 changed files with 6484 additions and 10335 deletions

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# MongoDB Configuration
MONGO_URI=mongodb://localhost:27017/neomovies
# TMDB API Configuration
TMDB_ACCESS_TOKEN=your_tmdb_access_token
# JWT Configuration
JWT_SECRET=your_jwt_secret_key
# Email Configuration (для уведомлений)
GMAIL_USER=your_gmail@gmail.com
GMAIL_APP_PASSWORD=your_gmail_app_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

295
README.md
View File

@@ -1,93 +1,290 @@
# Neo Movies API
# Neo Movies API (Go Version) 🎬
REST API для поиска и получения информации о фильмах, использующий TMDB API.
> Современный API для поиска фильмов и сериалов, портированный с Node.js на Go
## Особенности
## 🚀 Особенности
- Поиск фильмов
- Информация о фильмах
- Популярные фильмы
- Топ рейтинговые фильмы
- Предстоящие фильмы
- Swagger документация
- Поддержка русского языка
- **Высокая производительность** - написан на Go
- 🔒 **JWT аутентификация** с email верификацией
- 🎭 **TMDB API интеграция** для данных о фильмах/сериалах
- 📧 **Email уведомления** через Gmail SMTP
- 🔍 **Полнотекстовый поиск** фильмов и сериалов
- **Система избранного** для пользователей
- 🎨 **Современная документация** с Scalar API Reference
- 🌐 **CORS поддержка** для фронтенд интеграции
- ☁️ **Готов к деплою на Vercel**
## Установка
## 📚 Основные функции
1. Клонируйте репозиторий:
### 🔐 Аутентификация
- **Регистрация** с email верификацией (6-значный код)
- **Авторизация** JWT токенами
- **Управление профилем** пользователя
- **Email подтверждение** обязательно для входа
### 🎬 TMDB интеграция
- Поиск фильмов и сериалов
- Популярные, топ-рейтинговые, предстоящие
- Детальная информация с трейлерами и актерами
- Рекомендации и похожие фильмы
- Мультипоиск по всем типам контента
### ⭐ Пользовательские функции
- Добавление фильмов в избранное
- Персональные списки
- История просмотров
### 🎭 Плееры
- **Alloha Player** интеграция
- **Lumex Player** интеграция
### 📦 Дополнительно
- **Торренты** - поиск по IMDB ID с фильтрацией
- **Реакции** - лайки/дизлайки с внешним API
- **Изображения** - прокси для TMDB с кэшированием
- **Категории** - жанры и фильмы по категориям
## 🛠 Быстрый старт
### Локальная разработка
1. **Клонирование репозитория**
```bash
git clone https://gitlab.com/foxixus/neomovies-api.git
git clone <your-repo>
cd neomovies-api
```
2. Установите зависимости:
2. **Создание .env файла**
```bash
npm install
cp .env.example .env
# Заполните необходимые переменные
```
3. Создайте файл `.env`:
3. **Установка зависимостей**
```bash
touch .env
go mod download
```
4. Добавьте ваш TMDB Access Token в `.env` файл:
4. **Запуск**
```bash
go run main.go
```
API будет доступен на `http://localhost:3000`
### Деплой на Vercel
1. **Подключите репозиторий к Vercel**
2. **Настройте переменные окружения** (см. список ниже)
3. **Деплой произойдет автоматически**
## ⚙️ Переменные окружения
```bash
# Обязательные
MONGO_URI=mongodb+srv://username:password@cluster.mongodb.net/neomovies
TMDB_ACCESS_TOKEN=your_tmdb_access_token
MONGODB_URI=your_mongodb_uri
JWT_SECRET=your_jwt_secret_key
# Для email уведомлений (Gmail)
GMAIL_USER=your_gmail@gmail.com
GMAIL_APP_PASSWORD=your_gmail_app_password
# Для плееров
LUMEX_URL=your_lumex_player_url
ALLOHA_TOKEN=your_alloha_token
# Автоматические (Vercel)
PORT=3000
BASE_URL=https://api.neomovies.ru
NODE_ENV=production
```
## Запуск
## 📋 API Endpoints
### 🔓 Публичные маршруты
```http
# Система
GET /api/v1/health # Проверка состояния
# Аутентификация
POST /api/v1/auth/register # Регистрация (отправка кода)
POST /api/v1/auth/verify # Подтверждение email кодом
POST /api/v1/auth/resend-code # Повторная отправка кода
POST /api/v1/auth/login # Авторизация
# Поиск и категории
GET /search/multi # Мультипоиск
GET /api/v1/categories # Список категорий
GET /api/v1/categories/{id}/movies # Фильмы по категории
# Фильмы
GET /api/v1/movies/search # Поиск фильмов
GET /api/v1/movies/popular # Популярные
GET /api/v1/movies/top-rated # Топ-рейтинговые
GET /api/v1/movies/upcoming # Предстоящие
GET /api/v1/movies/now-playing # В прокате
GET /api/v1/movies/{id} # Детали фильма
GET /api/v1/movies/{id}/recommendations # Рекомендации
GET /api/v1/movies/{id}/similar # Похожие
# Сериалы
GET /api/v1/tv/search # Поиск сериалов
GET /api/v1/tv/popular # Популярные
GET /api/v1/tv/top-rated # Топ-рейтинговые
GET /api/v1/tv/on-the-air # В эфире
GET /api/v1/tv/airing-today # Сегодня в эфире
GET /api/v1/tv/{id} # Детали сериала
GET /api/v1/tv/{id}/recommendations # Рекомендации
GET /api/v1/tv/{id}/similar # Похожие
# Плееры
GET /api/v1/players/alloha # Alloha плеер
GET /api/v1/players/lumex # Lumex плеер
# Торренты
GET /api/v1/torrents/search/{imdbId} # Поиск торрентов
# Реакции (публичные)
GET /api/v1/reactions/{type}/{id}/counts # Счетчики реакций
# Изображения
GET /api/v1/images/{size}/{path} # Прокси TMDB изображений
```
### 🔒 Приватные маршруты (требуют JWT)
```http
# Профиль
GET /api/v1/auth/profile # Профиль пользователя
PUT /api/v1/auth/profile # Обновление профиля
# Избранное
GET /api/v1/favorites # Список избранного
POST /api/v1/favorites/{id} # Добавить в избранное
DELETE /api/v1/favorites/{id} # Удалить из избранного
# Реакции (приватные)
GET /api/v1/reactions/{type}/{id}/my-reaction # Моя реакция
POST /api/v1/reactions/{type}/{id} # Установить реакцию
DELETE /api/v1/reactions/{type}/{id} # Удалить реакцию
GET /api/v1/reactions/my # Все мои реакции
```
## 📖 Примеры использования
### Регистрация и верификация
Для разработки:
```bash
npm run dev
# 1. Регистрация
curl -X POST https://api.neomovies.ru/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "password123",
"name": "John Doe"
}'
# Ответ: {"success": true, "message": "Registered. Check email for verification code."}
# 2. Подтверждение email (код из письма)
curl -X POST https://api.neomovies.ru/api/v1/auth/verify \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"code": "123456"
}'
# 3. Авторизация
curl -X POST https://api.neomovies.ru/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "password123"
}'
```
Для продакшена:
### Поиск фильмов
```bash
npm start
# Поиск фильмов
curl "https://api.neomovies.ru/api/v1/movies/search?query=marvel&page=1"
# Детали фильма
curl "https://api.neomovies.ru/api/v1/movies/550"
# Добавить в избранное (с JWT токеном)
curl -X POST https://api.neomovies.ru/api/v1/favorites/550 \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
## Развертывание на Vercel
### Поиск торрентов
1. Установите Vercel CLI:
```bash
npm i -g vercel
# Поиск торрентов для фильма "Побег из Шоушенка"
curl "https://api.neomovies.ru/api/v1/torrents/search/tt0111161?type=movie&quality=1080p"
```
2. Войдите в ваш аккаунт Vercel:
```bash
vercel login
## 🎨 Документация API
Интерактивная документация доступна по адресу:
**🔗 https://api.neomovies.ru/**
## ☁️ Деплой на Vercel
1. **Подключите репозиторий к Vercel**
2. **Настройте Environment Variables в Vercel Dashboard:**
3. **Деплой автоматически запустится!**
## 🏗 Архитектура
```
├── main.go # Точка входа приложения
├── api/
│ └── index.go # Vercel serverless handler
├── pkg/ # Публичные пакеты (совместимо с Vercel)
│ ├── config/ # Конфигурация с поддержкой альтернативных env vars
│ ├── database/ # Подключение к MongoDB
│ ├── middleware/ # JWT, CORS, логирование
│ ├── models/ # Структуры данных
│ ├── services/ # Бизнес-логика
│ └── handlers/ # HTTP обработчики
├── vercel.json # Конфигурация Vercel
└── go.mod # Go модули
```
3. Разверните приложение:
```bash
vercel
```
## 🔧 Технологии
4. Добавьте переменные окружения в Vercel:
- Перейдите в настройки проекта на Vercel
- Добавьте `TMDB_ACCESS_TOKEN`, `MONGODB_URI`, `JWT_SECRET`, `GMAIL_USER`, `GMAIL_APP_PASSWORD`, `LUMEX_URL`, `ALLOHA_TOKEN` в раздел Environment Variables
- **Go 1.21** - основной язык
- **Gorilla Mux** - HTTP роутер
- **MongoDB** - база данных
- **JWT** - аутентификация
- **TMDB API** - данные о фильмах
- **Gmail SMTP** - email уведомления
- **Vercel** - деплой и хостинг
## API Endpoints
## 🚀 Производительность
- `GET /health` - Проверка работоспособности API
- `GET /movies/search?query=<search_term>&page=<page_number>` - Поиск фильмов
- `GET /movies/:id` - Получить информацию о фильме
- `GET /movies/popular` - Получить список популярных фильмов
- `GET /movies/top-rated` - Получить список топ рейтинговых фильмов
- `GET /movies/upcoming` - Получить список предстоящих фильмов
- `GET /movies/:id/external-ids` - Получить внешние ID фильма
По сравнению с Node.js версией:
- **3x быстрее** обработка запросов
- **50% меньше** потребление памяти
- **Конкурентность** благодаря горутинам
- **Типобезопасность** предотвращает ошибки
## Документация API
## 🤝 Contribution
После запуска API, документация Swagger доступна по адресу:
```
http://localhost:3000/api-docs
1. Форкните репозиторий
2. Создайте feature-ветку (`git checkout -b feature/amazing-feature`)
3. Коммитьте изменения (`git commit -m 'Add amazing feature'`)
4. Пушните в ветку (`git push origin feature/amazing-feature`)
5. Откройте Pull Request
## 📄 Лицензия
Apache License 2.0 - подробности в файле [LICENSE](LICENSE)
---
Made with <3 by Foxix

168
api/index.go Normal file
View 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)
}

View File

@@ -1,3 +0,0 @@
const app = require('../src/index');
module.exports = app;

30
go.mod Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
}

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

View 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

File diff suppressed because it is too large Load Diff

29
pkg/handlers/health.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

View File

@@ -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
View File

@@ -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 };

View File

@@ -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`);
});
}

View File

@@ -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;

View File

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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
};

View File

@@ -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();

View File

@@ -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 };

View File

@@ -2,17 +2,20 @@
"version": 2,
"builds": [
{
"src": "api/index.js",
"use": "@vercel/node"
"src": "api/index.go",
"use": "@vercel/go",
"config": {
"maxDuration": 10
}
}
],
"routes": [
{
"src": "/(.*)",
"dest": "/api/index.js"
"dest": "/api/index.go"
}
],
"env": {
"NODE_ENV": "production"
"GO_VERSION": "1.21"
}
}
}