mirror of
https://gitlab.com/foxixus/neomovies-api.git
synced 2025-10-28 01:48:51 +05:00
Rewrite api to Go
This commit is contained in:
62
pkg/config/config.go
Normal file
62
pkg/config/config.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MongoURI string
|
||||
TMDBAccessToken string
|
||||
JWTSecret string
|
||||
Port string
|
||||
BaseURL string
|
||||
NodeEnv string
|
||||
GmailUser string
|
||||
GmailPassword string
|
||||
LumexURL string
|
||||
AllohaToken string
|
||||
}
|
||||
|
||||
func New() *Config {
|
||||
// Добавляем отладочное логирование для Vercel
|
||||
mongoURI := getMongoURI()
|
||||
log.Printf("DEBUG: MongoDB URI configured (length: %d)", len(mongoURI))
|
||||
|
||||
return &Config{
|
||||
MongoURI: mongoURI,
|
||||
TMDBAccessToken: getEnv("TMDB_ACCESS_TOKEN", ""),
|
||||
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"),
|
||||
Port: getEnv("PORT", "3000"),
|
||||
BaseURL: getEnv("BASE_URL", "http://localhost:3000"),
|
||||
NodeEnv: getEnv("NODE_ENV", "development"),
|
||||
GmailUser: getEnv("GMAIL_USER", ""),
|
||||
GmailPassword: getEnv("GMAIL_APP_PASSWORD", ""),
|
||||
LumexURL: getEnv("LUMEX_URL", ""),
|
||||
AllohaToken: getEnv("ALLOHA_TOKEN", ""),
|
||||
}
|
||||
}
|
||||
|
||||
// getMongoURI проверяет различные варианты названий переменных для MongoDB URI
|
||||
func getMongoURI() string {
|
||||
// Проверяем различные возможные названия переменных
|
||||
envVars := []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"}
|
||||
|
||||
for _, envVar := range envVars {
|
||||
if value := os.Getenv(envVar); value != "" {
|
||||
log.Printf("DEBUG: Using %s for MongoDB connection", envVar)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// Если ни одна переменная не найдена, возвращаем пустую строку
|
||||
log.Printf("DEBUG: No MongoDB URI environment variable found")
|
||||
return ""
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
45
pkg/database/connection.go
Normal file
45
pkg/database/connection.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
)
|
||||
|
||||
var client *mongo.Client
|
||||
|
||||
func Connect(uri string) (*mongo.Database, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
client, err = mongo.Connect(ctx, options.Client().ApplyURI(uri))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Проверяем соединение
|
||||
err = client.Ping(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.Database("database"), nil
|
||||
}
|
||||
|
||||
func Disconnect() error {
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
return client.Disconnect(ctx)
|
||||
}
|
||||
|
||||
func GetClient() *mongo.Client {
|
||||
return client
|
||||
}
|
||||
159
pkg/handlers/auth.go
Normal file
159
pkg/handlers/auth.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.authService.Register(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
Message: "User registered successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.authService.Login(req)
|
||||
if err != nil {
|
||||
// Определяем правильный статус код в зависимости от ошибки
|
||||
statusCode := http.StatusBadRequest
|
||||
if err.Error() == "Account not activated. Please verify your email." {
|
||||
statusCode = http.StatusForbidden // 403 для неверифицированного email
|
||||
}
|
||||
http.Error(w, err.Error(), statusCode)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
Message: "Login successful",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.GetUserByID(userID)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: user,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var updates map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Удаляем поля, которые нельзя обновлять через этот эндпоинт
|
||||
delete(updates, "password")
|
||||
delete(updates, "email")
|
||||
delete(updates, "_id")
|
||||
delete(updates, "created_at")
|
||||
|
||||
user, err := h.authService.UpdateUser(userID, bson.M(updates))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to update user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: user,
|
||||
Message: "Profile updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// Верификация email
|
||||
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.VerifyEmailRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.authService.VerifyEmail(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// Повторная отправка кода верификации
|
||||
func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.ResendCodeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.authService.ResendVerificationCode(req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
96
pkg/handlers/categories.go
Normal file
96
pkg/handlers/categories.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type CategoriesHandler struct {
|
||||
tmdbService *services.TMDBService
|
||||
}
|
||||
|
||||
func NewCategoriesHandler(tmdbService *services.TMDBService) *CategoriesHandler {
|
||||
return &CategoriesHandler{
|
||||
tmdbService: tmdbService,
|
||||
}
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
func (h *CategoriesHandler) GetCategories(w http.ResponseWriter, r *http.Request) {
|
||||
// Получаем все жанры
|
||||
genresResponse, err := h.tmdbService.GetAllGenres()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Преобразуем жанры в категории
|
||||
var categories []Category
|
||||
for _, genre := range genresResponse.Genres {
|
||||
slug := generateSlug(genre.Name)
|
||||
categories = append(categories, Category{
|
||||
ID: genre.ID,
|
||||
Name: genre.Name,
|
||||
Slug: slug,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: categories,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
categoryID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid category ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
if language == "" {
|
||||
language = "ru-RU"
|
||||
}
|
||||
|
||||
// Используем discover API для получения фильмов по жанру
|
||||
movies, err := h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
})
|
||||
}
|
||||
|
||||
func generateSlug(name string) string {
|
||||
// Простая функция для создания slug из названия
|
||||
// В реальном проекте стоит использовать более сложную логику
|
||||
result := ""
|
||||
for _, char := range name {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
|
||||
result += string(char)
|
||||
} else if char == ' ' {
|
||||
result += "-"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
1332
pkg/handlers/docs.go
Normal file
1332
pkg/handlers/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
29
pkg/handlers/health.go
Normal file
29
pkg/handlers/health.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
func HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
health := map[string]interface{}{
|
||||
"status": "OK",
|
||||
"timestamp": time.Now().UTC(),
|
||||
"service": "neomovies-api",
|
||||
"version": "2.0.0",
|
||||
"uptime": time.Since(startTime),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Message: "API is running",
|
||||
Data: health,
|
||||
})
|
||||
}
|
||||
|
||||
var startTime = time.Now()
|
||||
147
pkg/handlers/images.go
Normal file
147
pkg/handlers/images.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type ImagesHandler struct{}
|
||||
|
||||
func NewImagesHandler() *ImagesHandler {
|
||||
return &ImagesHandler{}
|
||||
}
|
||||
|
||||
const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
||||
|
||||
func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
size := vars["size"]
|
||||
imagePath := vars["path"]
|
||||
|
||||
if size == "" || imagePath == "" {
|
||||
http.Error(w, "Size and path are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Если запрашивается placeholder, возвращаем локальный файл
|
||||
if imagePath == "placeholder.jpg" {
|
||||
h.servePlaceholder(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем размер изображения
|
||||
validSizes := []string{"w92", "w154", "w185", "w342", "w500", "w780", "original"}
|
||||
if !h.isValidSize(size, validSizes) {
|
||||
size = "original"
|
||||
}
|
||||
|
||||
// Формируем URL изображения
|
||||
imageURL := fmt.Sprintf("%s/%s/%s", TMDB_IMAGE_BASE_URL, size, imagePath)
|
||||
|
||||
// Получаем изображение
|
||||
resp, err := http.Get(imageURL)
|
||||
if err != nil {
|
||||
h.servePlaceholder(w, r)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
h.servePlaceholder(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Устанавливаем заголовки
|
||||
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000") // кэшируем на 1 год
|
||||
|
||||
// Передаем изображение клиенту
|
||||
_, err = io.Copy(w, resp.Body)
|
||||
if err != nil {
|
||||
// Если ошибка при копировании, отдаем placeholder
|
||||
h.servePlaceholder(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) {
|
||||
// Попробуем найти placeholder изображение
|
||||
placeholderPaths := []string{
|
||||
"./assets/placeholder.jpg",
|
||||
"./public/images/placeholder.jpg",
|
||||
"./static/placeholder.jpg",
|
||||
}
|
||||
|
||||
var placeholderPath string
|
||||
for _, path := range placeholderPaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
placeholderPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if placeholderPath == "" {
|
||||
// Если placeholder не найден, создаем простую SVG заглушку
|
||||
h.serveSVGPlaceholder(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(placeholderPath)
|
||||
if err != nil {
|
||||
h.serveSVGPlaceholder(w, r)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Определяем content-type по расширению
|
||||
ext := strings.ToLower(filepath.Ext(placeholderPath))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
case ".png":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
case ".gif":
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
case ".webp":
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
default:
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600") // кэшируем placeholder на 1 час
|
||||
|
||||
_, err = io.Copy(w, file)
|
||||
if err != nil {
|
||||
h.serveSVGPlaceholder(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) serveSVGPlaceholder(w http.ResponseWriter, r *http.Request) {
|
||||
svgPlaceholder := `<svg width="300" height="450" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#f0f0f0"/>
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" fill="#666">
|
||||
Изображение не найдено
|
||||
</text>
|
||||
</svg>`
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write([]byte(svgPlaceholder))
|
||||
}
|
||||
|
||||
func (h *ImagesHandler) isValidSize(size string, validSizes []string) bool {
|
||||
for _, validSize := range validSizes {
|
||||
if size == validSize {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
294
pkg/handlers/movie.go
Normal file
294
pkg/handlers/movie.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type MovieHandler struct {
|
||||
movieService *services.MovieService
|
||||
}
|
||||
|
||||
func NewMovieHandler(movieService *services.MovieService) *MovieHandler {
|
||||
return &MovieHandler{
|
||||
movieService: movieService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MovieHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("query")
|
||||
if query == "" {
|
||||
http.Error(w, "Query parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
region := r.URL.Query().Get("region")
|
||||
year := getIntQuery(r, "year", 0)
|
||||
|
||||
movies, err := h.movieService.Search(query, page, language, region, year)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
movie, err := h.movieService.GetByID(id, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movie,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) Popular(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
region := r.URL.Query().Get("region")
|
||||
|
||||
movies, err := h.movieService.GetPopular(page, language, region)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) TopRated(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
region := r.URL.Query().Get("region")
|
||||
|
||||
movies, err := h.movieService.GetTopRated(page, language, region)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) Upcoming(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
region := r.URL.Query().Get("region")
|
||||
|
||||
movies, err := h.movieService.GetUpcoming(page, language, region)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) NowPlaying(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
region := r.URL.Query().Get("region")
|
||||
|
||||
movies, err := h.movieService.GetNowPlaying(page, language, region)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
movies, err := h.movieService.GetRecommendations(id, page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) GetSimilar(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
movies, err := h.movieService.GetSimilar(id, page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
movies, err := h.movieService.GetFavorites(userID, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) AddToFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
movieID := vars["id"]
|
||||
|
||||
err := h.movieService.AddToFavorites(userID, movieID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Message: "Movie added to favorites",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) RemoveFromFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
movieID := vars["id"]
|
||||
|
||||
err := h.movieService.RemoveFromFavorites(userID, movieID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Message: "Movie removed from favorites",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
externalIDs, err := h.movieService.GetExternalIDs(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: externalIDs,
|
||||
})
|
||||
}
|
||||
|
||||
func getIntQuery(r *http.Request, key string, defaultValue int) int {
|
||||
str := r.URL.Query().Get(key)
|
||||
if str == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(str)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
142
pkg/handlers/players.go
Normal file
142
pkg/handlers/players.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"neomovies-api/pkg/config"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type PlayersHandler struct {
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewPlayersHandler(cfg *config.Config) *PlayersHandler {
|
||||
return &PlayersHandler{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("GetAllohaPlayer called: %s %s", r.Method, r.URL.Path)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
log.Printf("Route vars: %+v", vars)
|
||||
|
||||
imdbID := vars["imdb_id"]
|
||||
if imdbID == "" {
|
||||
log.Printf("Error: imdb_id is empty")
|
||||
http.Error(w, "imdb_id path param is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Processing imdb_id: %s", imdbID)
|
||||
|
||||
if h.config.AllohaToken == "" {
|
||||
log.Printf("Error: ALLOHA_TOKEN is missing")
|
||||
http.Error(w, "Server misconfiguration: ALLOHA_TOKEN missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
idParam := fmt.Sprintf("imdb=%s", url.QueryEscape(imdbID))
|
||||
apiURL := fmt.Sprintf("https://api.alloha.tv/?token=%s&%s", h.config.AllohaToken, idParam)
|
||||
log.Printf("Calling Alloha API: %s", apiURL)
|
||||
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
log.Printf("Error calling Alloha API: %v", err)
|
||||
http.Error(w, "Failed to fetch from Alloha API", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
log.Printf("Alloha API response status: %d", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
http.Error(w, fmt.Sprintf("Alloha API error: %d", resp.StatusCode), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Error reading Alloha response: %v", err)
|
||||
http.Error(w, "Failed to read Alloha response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alloha API response body: %s", string(body))
|
||||
|
||||
var allohaResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Iframe string `json:"iframe"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &allohaResponse); err != nil {
|
||||
log.Printf("Error unmarshaling JSON: %v", err)
|
||||
http.Error(w, "Invalid JSON from Alloha", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
if allohaResponse.Status != "success" || allohaResponse.Data.Iframe == "" {
|
||||
log.Printf("Video not found or empty iframe")
|
||||
http.Error(w, "Video not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
iframeCode := allohaResponse.Data.Iframe
|
||||
if !strings.Contains(iframeCode, "<") {
|
||||
iframeCode = fmt.Sprintf(`<iframe src="%s" allowfullscreen style="border:none;width:100%%;height:100%%"></iframe>`, iframeCode)
|
||||
}
|
||||
|
||||
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Alloha Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframeCode)
|
||||
|
||||
// Авто-исправление экранированных кавычек
|
||||
htmlDoc = strings.ReplaceAll(htmlDoc, `\"`, `"`)
|
||||
htmlDoc = strings.ReplaceAll(htmlDoc, `\'`, `'`)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(htmlDoc))
|
||||
|
||||
log.Printf("Successfully served Alloha player for imdb_id: %s", imdbID)
|
||||
}
|
||||
|
||||
func (h *PlayersHandler) GetLumexPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("GetLumexPlayer called: %s %s", r.Method, r.URL.Path)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
log.Printf("Route vars: %+v", vars)
|
||||
|
||||
imdbID := vars["imdb_id"]
|
||||
if imdbID == "" {
|
||||
log.Printf("Error: imdb_id is empty")
|
||||
http.Error(w, "imdb_id path param is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Processing imdb_id: %s", imdbID)
|
||||
|
||||
if h.config.LumexURL == "" {
|
||||
log.Printf("Error: LUMEX_URL is missing")
|
||||
http.Error(w, "Server misconfiguration: LUMEX_URL missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s?imdb_id=%s", h.config.LumexURL, url.QueryEscape(imdbID))
|
||||
log.Printf("Generated Lumex URL: %s", url)
|
||||
|
||||
iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, url)
|
||||
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Lumex Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframe)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(htmlDoc))
|
||||
|
||||
log.Printf("Successfully served Lumex player for imdb_id: %s", imdbID)
|
||||
}
|
||||
171
pkg/handlers/reactions.go
Normal file
171
pkg/handlers/reactions.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type ReactionsHandler struct {
|
||||
reactionsService *services.ReactionsService
|
||||
}
|
||||
|
||||
func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler {
|
||||
return &ReactionsHandler{
|
||||
reactionsService: reactionsService,
|
||||
}
|
||||
}
|
||||
|
||||
// Получить счетчики реакций для медиа (публичный эндпоинт)
|
||||
func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
mediaType := vars["mediaType"]
|
||||
mediaID := vars["mediaId"]
|
||||
|
||||
if mediaType == "" || mediaID == "" {
|
||||
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
counts, err := h.reactionsService.GetReactionCounts(mediaType, mediaID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(counts)
|
||||
}
|
||||
|
||||
// Получить реакцию текущего пользователя (требует авторизации)
|
||||
func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
mediaType := vars["mediaType"]
|
||||
mediaID := vars["mediaId"]
|
||||
|
||||
if mediaType == "" || mediaID == "" {
|
||||
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reaction, err := h.reactionsService.GetUserReaction(userID, mediaType, mediaID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if reaction == nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(reaction)
|
||||
}
|
||||
}
|
||||
|
||||
// Установить реакцию пользователя (требует авторизации)
|
||||
func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
mediaType := vars["mediaType"]
|
||||
mediaID := vars["mediaId"]
|
||||
|
||||
if mediaType == "" || mediaID == "" {
|
||||
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var request struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if request.Type == "" {
|
||||
http.Error(w, "Reaction type is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.reactionsService.SetUserReaction(userID, mediaType, mediaID, request.Type)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Message: "Reaction set successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// Удалить реакцию пользователя (требует авторизации)
|
||||
func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
mediaType := vars["mediaType"]
|
||||
mediaID := vars["mediaId"]
|
||||
|
||||
if mediaType == "" || mediaID == "" {
|
||||
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.reactionsService.RemoveUserReaction(userID, mediaType, mediaID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Message: "Reaction removed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// Получить все реакции пользователя (требует авторизации)
|
||||
func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
limit := getIntQuery(r, "limit", 50)
|
||||
|
||||
reactions, err := h.reactionsService.GetUserReactions(userID, limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: reactions,
|
||||
})
|
||||
}
|
||||
45
pkg/handlers/search.go
Normal file
45
pkg/handlers/search.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type SearchHandler struct {
|
||||
tmdbService *services.TMDBService
|
||||
}
|
||||
|
||||
func NewSearchHandler(tmdbService *services.TMDBService) *SearchHandler {
|
||||
return &SearchHandler{
|
||||
tmdbService: tmdbService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SearchHandler) MultiSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("query")
|
||||
if query == "" {
|
||||
http.Error(w, "Query parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
if language == "" {
|
||||
language = "ru-RU"
|
||||
}
|
||||
|
||||
results, err := h.tmdbService.SearchMulti(query, page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: results,
|
||||
})
|
||||
}
|
||||
367
pkg/handlers/torrents.go
Normal file
367
pkg/handlers/torrents.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type TorrentsHandler struct {
|
||||
torrentService *services.TorrentService
|
||||
tmdbService *services.TMDBService
|
||||
}
|
||||
|
||||
func NewTorrentsHandler(torrentService *services.TorrentService, tmdbService *services.TMDBService) *TorrentsHandler {
|
||||
return &TorrentsHandler{
|
||||
torrentService: torrentService,
|
||||
tmdbService: tmdbService,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchTorrents - поиск торрентов по IMDB ID
|
||||
func (h *TorrentsHandler) SearchTorrents(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
imdbID := vars["imdbId"]
|
||||
|
||||
if imdbID == "" {
|
||||
http.Error(w, "IMDB ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Параметры запроса
|
||||
mediaType := r.URL.Query().Get("type")
|
||||
if mediaType == "" {
|
||||
mediaType = "movie"
|
||||
}
|
||||
|
||||
// Создаем опции поиска
|
||||
options := &models.TorrentSearchOptions{
|
||||
ContentType: mediaType,
|
||||
}
|
||||
|
||||
// Качество
|
||||
if quality := r.URL.Query().Get("quality"); quality != "" {
|
||||
options.Quality = strings.Split(quality, ",")
|
||||
}
|
||||
|
||||
// Минимальное и максимальное качество
|
||||
options.MinQuality = r.URL.Query().Get("minQuality")
|
||||
options.MaxQuality = r.URL.Query().Get("maxQuality")
|
||||
|
||||
// Исключаемые качества
|
||||
if excludeQualities := r.URL.Query().Get("excludeQualities"); excludeQualities != "" {
|
||||
options.ExcludeQualities = strings.Split(excludeQualities, ",")
|
||||
}
|
||||
|
||||
// HDR
|
||||
if hdr := r.URL.Query().Get("hdr"); hdr != "" {
|
||||
if hdrBool, err := strconv.ParseBool(hdr); err == nil {
|
||||
options.HDR = &hdrBool
|
||||
}
|
||||
}
|
||||
|
||||
// HEVC
|
||||
if hevc := r.URL.Query().Get("hevc"); hevc != "" {
|
||||
if hevcBool, err := strconv.ParseBool(hevc); err == nil {
|
||||
options.HEVC = &hevcBool
|
||||
}
|
||||
}
|
||||
|
||||
// Сортировка
|
||||
options.SortBy = r.URL.Query().Get("sortBy")
|
||||
if options.SortBy == "" {
|
||||
options.SortBy = "seeders"
|
||||
}
|
||||
|
||||
options.SortOrder = r.URL.Query().Get("sortOrder")
|
||||
if options.SortOrder == "" {
|
||||
options.SortOrder = "desc"
|
||||
}
|
||||
|
||||
// Группировка
|
||||
if groupByQuality := r.URL.Query().Get("groupByQuality"); groupByQuality == "true" {
|
||||
options.GroupByQuality = true
|
||||
}
|
||||
|
||||
if groupBySeason := r.URL.Query().Get("groupBySeason"); groupBySeason == "true" {
|
||||
options.GroupBySeason = true
|
||||
}
|
||||
|
||||
// Сезон для сериалов
|
||||
if season := r.URL.Query().Get("season"); season != "" {
|
||||
if seasonInt, err := strconv.Atoi(season); err == nil {
|
||||
options.Season = &seasonInt
|
||||
}
|
||||
}
|
||||
|
||||
// Поиск торрентов
|
||||
results, err := h.torrentService.SearchTorrentsByIMDbID(h.tmdbService, imdbID, mediaType, options)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Формируем ответ с группировкой если необходимо
|
||||
response := map[string]interface{}{
|
||||
"imdbId": imdbID,
|
||||
"type": mediaType,
|
||||
"total": results.Total,
|
||||
}
|
||||
|
||||
if options.Season != nil {
|
||||
response["season"] = *options.Season
|
||||
}
|
||||
|
||||
// Применяем группировку если запрошена
|
||||
if options.GroupByQuality && options.GroupBySeason {
|
||||
// Группируем сначала по сезонам, затем по качеству внутри каждого сезона
|
||||
seasonGroups := h.torrentService.GroupBySeason(results.Results)
|
||||
finalGroups := make(map[string]map[string][]models.TorrentResult)
|
||||
|
||||
for season, torrents := range seasonGroups {
|
||||
qualityGroups := h.torrentService.GroupByQuality(torrents)
|
||||
finalGroups[season] = qualityGroups
|
||||
}
|
||||
|
||||
response["grouped"] = true
|
||||
response["groups"] = finalGroups
|
||||
} else if options.GroupByQuality {
|
||||
groups := h.torrentService.GroupByQuality(results.Results)
|
||||
response["grouped"] = true
|
||||
response["groups"] = groups
|
||||
} else if options.GroupBySeason {
|
||||
groups := h.torrentService.GroupBySeason(results.Results)
|
||||
response["grouped"] = true
|
||||
response["groups"] = groups
|
||||
} else {
|
||||
response["grouped"] = false
|
||||
response["results"] = results.Results
|
||||
}
|
||||
|
||||
if len(results.Results) == 0 {
|
||||
response["error"] = "No torrents found for this IMDB ID"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchMovies - поиск фильмов по названию
|
||||
func (h *TorrentsHandler) SearchMovies(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.URL.Query().Get("title")
|
||||
originalTitle := r.URL.Query().Get("originalTitle")
|
||||
year := r.URL.Query().Get("year")
|
||||
|
||||
if title == "" && originalTitle == "" {
|
||||
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.torrentService.SearchMovies(title, originalTitle, year)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"title": title,
|
||||
"originalTitle": originalTitle,
|
||||
"year": year,
|
||||
"type": "movie",
|
||||
"total": results.Total,
|
||||
"results": results.Results,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchSeries - поиск сериалов по названию с поддержкой сезонов
|
||||
func (h *TorrentsHandler) SearchSeries(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.URL.Query().Get("title")
|
||||
originalTitle := r.URL.Query().Get("originalTitle")
|
||||
year := r.URL.Query().Get("year")
|
||||
|
||||
if title == "" && originalTitle == "" {
|
||||
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var season *int
|
||||
if seasonStr := r.URL.Query().Get("season"); seasonStr != "" {
|
||||
if seasonInt, err := strconv.Atoi(seasonStr); err == nil {
|
||||
season = &seasonInt
|
||||
}
|
||||
}
|
||||
|
||||
results, err := h.torrentService.SearchSeries(title, originalTitle, year, season)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"title": title,
|
||||
"originalTitle": originalTitle,
|
||||
"year": year,
|
||||
"type": "series",
|
||||
"total": results.Total,
|
||||
"results": results.Results,
|
||||
}
|
||||
|
||||
if season != nil {
|
||||
response["season"] = *season
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchAnime - поиск аниме по названию
|
||||
func (h *TorrentsHandler) SearchAnime(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.URL.Query().Get("title")
|
||||
originalTitle := r.URL.Query().Get("originalTitle")
|
||||
year := r.URL.Query().Get("year")
|
||||
|
||||
if title == "" && originalTitle == "" {
|
||||
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.torrentService.SearchAnime(title, originalTitle, year)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"title": title,
|
||||
"originalTitle": originalTitle,
|
||||
"year": year,
|
||||
"type": "anime",
|
||||
"total": results.Total,
|
||||
"results": results.Results,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAvailableSeasons - получение доступных сезонов для сериала
|
||||
func (h *TorrentsHandler) GetAvailableSeasons(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.URL.Query().Get("title")
|
||||
originalTitle := r.URL.Query().Get("originalTitle")
|
||||
year := r.URL.Query().Get("year")
|
||||
|
||||
if title == "" && originalTitle == "" {
|
||||
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
seasons, err := h.torrentService.GetAvailableSeasons(title, originalTitle, year)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"title": title,
|
||||
"originalTitle": originalTitle,
|
||||
"year": year,
|
||||
"seasons": seasons,
|
||||
"total": len(seasons),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchByQuery - универсальный поиск торрентов
|
||||
func (h *TorrentsHandler) SearchByQuery(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("query")
|
||||
if query == "" {
|
||||
http.Error(w, "Query is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
contentType := r.URL.Query().Get("type")
|
||||
if contentType == "" {
|
||||
contentType = "movie"
|
||||
}
|
||||
|
||||
year := r.URL.Query().Get("year")
|
||||
|
||||
// Формируем параметры поиска
|
||||
params := map[string]string{
|
||||
"query": query,
|
||||
}
|
||||
|
||||
if year != "" {
|
||||
params["year"] = year
|
||||
}
|
||||
|
||||
// Устанавливаем тип контента и категорию
|
||||
switch contentType {
|
||||
case "movie":
|
||||
params["is_serial"] = "1"
|
||||
params["category"] = "2000"
|
||||
case "series", "tv":
|
||||
params["is_serial"] = "2"
|
||||
params["category"] = "5000"
|
||||
case "anime":
|
||||
params["is_serial"] = "5"
|
||||
params["category"] = "5070"
|
||||
}
|
||||
|
||||
results, err := h.torrentService.SearchTorrents(params)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Применяем фильтрацию по типу контента
|
||||
options := &models.TorrentSearchOptions{
|
||||
ContentType: contentType,
|
||||
}
|
||||
results.Results = h.torrentService.FilterByContentType(results.Results, options.ContentType)
|
||||
results.Total = len(results.Results)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"query": query,
|
||||
"type": contentType,
|
||||
"year": year,
|
||||
"total": results.Total,
|
||||
"results": results.Results,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: response,
|
||||
})
|
||||
}
|
||||
206
pkg/handlers/tv.go
Normal file
206
pkg/handlers/tv.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type TVHandler struct {
|
||||
tvService *services.TVService
|
||||
}
|
||||
|
||||
func NewTVHandler(tvService *services.TVService) *TVHandler {
|
||||
return &TVHandler{
|
||||
tvService: tvService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TVHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("query")
|
||||
if query == "" {
|
||||
http.Error(w, "Query parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
year := getIntQuery(r, "first_air_date_year", 0)
|
||||
|
||||
tvShows, err := h.tvService.Search(query, page, language, year)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) GetByID(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShow, err := h.tvService.GetByID(id, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: tvShow,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) Popular(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetPopular(page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) TopRated(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetTopRated(page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) OnTheAir(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetOnTheAir(page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) AiringToday(w http.ResponseWriter, r *http.Request) {
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetAiringToday(page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetRecommendations(id, page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) GetSimilar(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
page := getIntQuery(r, "page", 1)
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
tvShows, err := h.tvService.GetSimilar(id, page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: tvShows,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TVHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
externalIDs, err := h.tvService.GetExternalIDs(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: externalIDs,
|
||||
})
|
||||
}
|
||||
63
pkg/middleware/auth.go
Normal file
63
pkg/middleware/auth.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserIDKey contextKey = "userID"
|
||||
|
||||
func JWTAuth(secret string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Authorization header required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if tokenString == authHeader {
|
||||
http.Error(w, "Bearer token required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := claims["user_id"].(string)
|
||||
if !ok {
|
||||
http.Error(w, "Invalid user ID in token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserIDFromContext(ctx context.Context) (string, bool) {
|
||||
userID, ok := ctx.Value(UserIDKey).(string)
|
||||
return userID, ok
|
||||
}
|
||||
292
pkg/models/movie.go
Normal file
292
pkg/models/movie.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package models
|
||||
|
||||
type Movie struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
Genres []Genre `json:"genres"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
Adult bool `json:"adult"`
|
||||
Video bool `json:"video"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
Runtime int `json:"runtime,omitempty"`
|
||||
Budget int64 `json:"budget,omitempty"`
|
||||
Revenue int64 `json:"revenue,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Tagline string `json:"tagline,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
IMDbID string `json:"imdb_id,omitempty"`
|
||||
BelongsToCollection *Collection `json:"belongs_to_collection,omitempty"`
|
||||
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
|
||||
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
|
||||
SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"`
|
||||
}
|
||||
|
||||
type TVShow struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
LastAirDate string `json:"last_air_date"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
Genres []Genre `json:"genres"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
NumberOfEpisodes int `json:"number_of_episodes,omitempty"`
|
||||
NumberOfSeasons int `json:"number_of_seasons,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
InProduction bool `json:"in_production,omitempty"`
|
||||
Languages []string `json:"languages,omitempty"`
|
||||
Networks []Network `json:"networks,omitempty"`
|
||||
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
|
||||
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
|
||||
SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"`
|
||||
CreatedBy []Creator `json:"created_by,omitempty"`
|
||||
EpisodeRunTime []int `json:"episode_run_time,omitempty"`
|
||||
Seasons []Season `json:"seasons,omitempty"`
|
||||
}
|
||||
|
||||
// MultiSearchResult для мультипоиска
|
||||
type MultiSearchResult struct {
|
||||
ID int `json:"id"`
|
||||
MediaType string `json:"media_type"` // "movie" или "tv"
|
||||
Title string `json:"title,omitempty"` // для фильмов
|
||||
Name string `json:"name,omitempty"` // для сериалов
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ReleaseDate string `json:"release_date,omitempty"` // для фильмов
|
||||
FirstAirDate string `json:"first_air_date,omitempty"` // для сериалов
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
Adult bool `json:"adult"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
}
|
||||
|
||||
type MultiSearchResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []MultiSearchResult `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type Genre struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type GenresResponse struct {
|
||||
Genres []Genre `json:"genres"`
|
||||
}
|
||||
|
||||
type ExternalIDs struct {
|
||||
ID int `json:"id"`
|
||||
IMDbID string `json:"imdb_id"`
|
||||
TVDBID int `json:"tvdb_id,omitempty"`
|
||||
WikidataID string `json:"wikidata_id"`
|
||||
FacebookID string `json:"facebook_id"`
|
||||
InstagramID string `json:"instagram_id"`
|
||||
TwitterID string `json:"twitter_id"`
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
}
|
||||
|
||||
type ProductionCompany struct {
|
||||
ID int `json:"id"`
|
||||
LogoPath string `json:"logo_path"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry string `json:"origin_country"`
|
||||
}
|
||||
|
||||
type ProductionCountry struct {
|
||||
ISO31661 string `json:"iso_3166_1"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SpokenLanguage struct {
|
||||
EnglishName string `json:"english_name"`
|
||||
ISO6391 string `json:"iso_639_1"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Network struct {
|
||||
ID int `json:"id"`
|
||||
LogoPath string `json:"logo_path"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry string `json:"origin_country"`
|
||||
}
|
||||
|
||||
type Creator struct {
|
||||
ID int `json:"id"`
|
||||
CreditID string `json:"credit_id"`
|
||||
Name string `json:"name"`
|
||||
Gender int `json:"gender"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
}
|
||||
|
||||
type Season struct {
|
||||
AirDate string `json:"air_date"`
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
SeasonNumber int `json:"season_number"`
|
||||
}
|
||||
|
||||
type TMDBResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []Movie `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBTVResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []TVShow `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type SearchParams struct {
|
||||
Query string `json:"query"`
|
||||
Page int `json:"page"`
|
||||
Language string `json:"language"`
|
||||
Region string `json:"region"`
|
||||
Year int `json:"year"`
|
||||
PrimaryReleaseYear int `json:"primary_release_year"`
|
||||
}
|
||||
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Модели для торрентов
|
||||
type TorrentResult struct {
|
||||
Title string `json:"title"`
|
||||
Tracker string `json:"tracker"`
|
||||
Size string `json:"size"`
|
||||
Seeders int `json:"seeders"`
|
||||
Peers int `json:"peers"`
|
||||
Leechers int `json:"leechers"`
|
||||
Quality string `json:"quality"`
|
||||
Voice []string `json:"voice,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
Seasons []int `json:"seasons,omitempty"`
|
||||
Category string `json:"category"`
|
||||
MagnetLink string `json:"magnet"`
|
||||
TorrentLink string `json:"torrent_link,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
PublishDate string `json:"publish_date"`
|
||||
AddedDate string `json:"added_date,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type TorrentSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Results []TorrentResult `json:"results"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// RedAPI специфичные структуры
|
||||
type RedAPIResponse struct {
|
||||
Results []RedAPITorrent `json:"Results"`
|
||||
}
|
||||
|
||||
type RedAPITorrent struct {
|
||||
Title string `json:"Title"`
|
||||
Tracker string `json:"Tracker"`
|
||||
Size interface{} `json:"Size"` // Может быть string или number
|
||||
Seeders int `json:"Seeders"`
|
||||
Peers int `json:"Peers"`
|
||||
MagnetUri string `json:"MagnetUri"`
|
||||
PublishDate string `json:"PublishDate"`
|
||||
CategoryDesc string `json:"CategoryDesc"`
|
||||
Details string `json:"Details"`
|
||||
Info *RedAPITorrentInfo `json:"Info,omitempty"`
|
||||
}
|
||||
|
||||
type RedAPITorrentInfo struct {
|
||||
Quality interface{} `json:"quality,omitempty"` // Может быть string или number
|
||||
Voices []string `json:"voices,omitempty"`
|
||||
Types []string `json:"types,omitempty"`
|
||||
Seasons []int `json:"seasons,omitempty"`
|
||||
}
|
||||
|
||||
// Alloha API структуры для получения информации о фильмах
|
||||
type AllohaResponse struct {
|
||||
Data *AllohaData `json:"data"`
|
||||
}
|
||||
|
||||
type AllohaData struct {
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
}
|
||||
|
||||
// Опции поиска торрентов
|
||||
type TorrentSearchOptions struct {
|
||||
Season *int
|
||||
Quality []string
|
||||
MinQuality string
|
||||
MaxQuality string
|
||||
ExcludeQualities []string
|
||||
HDR *bool
|
||||
HEVC *bool
|
||||
SortBy string
|
||||
SortOrder string
|
||||
GroupByQuality bool
|
||||
GroupBySeason bool
|
||||
ContentType string
|
||||
}
|
||||
|
||||
// Модели для плееров
|
||||
type PlayerResponse struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Iframe string `json:"iframe,omitempty"`
|
||||
}
|
||||
|
||||
// Модели для реакций
|
||||
type Reaction struct {
|
||||
ID string `json:"id" bson:"_id,omitempty"`
|
||||
UserID string `json:"userId" bson:"userId"`
|
||||
MediaID string `json:"mediaId" bson:"mediaId"`
|
||||
Type string `json:"type" bson:"type"`
|
||||
Created string `json:"created" bson:"created"`
|
||||
}
|
||||
|
||||
type ReactionCounts struct {
|
||||
Fire int `json:"fire"`
|
||||
Nice int `json:"nice"`
|
||||
Think int `json:"think"`
|
||||
Bore int `json:"bore"`
|
||||
Shit int `json:"shit"`
|
||||
}
|
||||
48
pkg/models/user.go
Normal file
48
pkg/models/user.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
|
||||
Email string `json:"email" bson:"email" validate:"required,email"`
|
||||
Password string `json:"-" bson:"password" validate:"required,min=6"`
|
||||
Name string `json:"name" bson:"name" validate:"required"`
|
||||
Avatar string `json:"avatar" bson:"avatar"`
|
||||
Favorites []string `json:"favorites" bson:"favorites"`
|
||||
Verified bool `json:"verified" bson:"verified"`
|
||||
VerificationCode string `json:"-" bson:"verificationCode,omitempty"`
|
||||
VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"`
|
||||
IsAdmin bool `json:"isAdmin" bson:"isAdmin"`
|
||||
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
|
||||
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
type VerifyEmailRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
|
||||
type ResendCodeRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
91
pkg/monitor/monitor.go
Normal file
91
pkg/monitor/monitor.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RequestMonitor создает middleware для мониторинга запросов в стиле htop
|
||||
func RequestMonitor() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Создаем wrapper для ResponseWriter чтобы получить статус код
|
||||
ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
// Выполняем запрос
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
// Вычисляем время выполнения
|
||||
duration := time.Since(start)
|
||||
|
||||
// Форматируем URL (обрезаем если слишком длинный)
|
||||
url := r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
url += "?" + r.URL.RawQuery
|
||||
}
|
||||
if len(url) > 60 {
|
||||
url = url[:57] + "..."
|
||||
}
|
||||
|
||||
// Определяем цвет статуса
|
||||
statusColor := getStatusColor(ww.statusCode)
|
||||
methodColor := getMethodColor(r.Method)
|
||||
|
||||
// Выводим информацию о запросе
|
||||
fmt.Printf("\033[2K\r%s%-6s\033[0m %s%-3d\033[0m │ %-60s │ %6.2fms\n",
|
||||
methodColor, r.Method,
|
||||
statusColor, ww.statusCode,
|
||||
url,
|
||||
float64(duration.Nanoseconds())/1000000,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// getStatusColor возвращает ANSI цвет для статус кода
|
||||
func getStatusColor(status int) string {
|
||||
switch {
|
||||
case status >= 200 && status < 300:
|
||||
return "\033[32m" // Зеленый
|
||||
case status >= 300 && status < 400:
|
||||
return "\033[33m" // Желтый
|
||||
case status >= 400 && status < 500:
|
||||
return "\033[31m" // Красный
|
||||
case status >= 500:
|
||||
return "\033[35m" // Фиолетовый
|
||||
default:
|
||||
return "\033[37m" // Белый
|
||||
}
|
||||
}
|
||||
|
||||
// getMethodColor возвращает ANSI цвет для HTTP метода
|
||||
func getMethodColor(method string) string {
|
||||
switch strings.ToUpper(method) {
|
||||
case "GET":
|
||||
return "\033[34m" // Синий
|
||||
case "POST":
|
||||
return "\033[32m" // Зеленый
|
||||
case "PUT":
|
||||
return "\033[33m" // Желтый
|
||||
case "DELETE":
|
||||
return "\033[31m" // Красный
|
||||
case "PATCH":
|
||||
return "\033[36m" // Циан
|
||||
default:
|
||||
return "\033[37m" // Белый
|
||||
}
|
||||
}
|
||||
353
pkg/services/auth.go
Normal file
353
pkg/services/auth.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
db *mongo.Database
|
||||
jwtSecret string
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService) *AuthService {
|
||||
service := &AuthService{
|
||||
db: db,
|
||||
jwtSecret: jwtSecret,
|
||||
emailService: emailService,
|
||||
}
|
||||
|
||||
// Запускаем тест подключения к базе данных
|
||||
go service.testDatabaseConnection()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// testDatabaseConnection тестирует подключение к базе данных и выводит информацию о пользователях
|
||||
func (s *AuthService) testDatabaseConnection() {
|
||||
ctx := context.Background()
|
||||
|
||||
fmt.Println("=== DATABASE CONNECTION TEST ===")
|
||||
|
||||
// Проверяем подключение
|
||||
err := s.db.Client().Ping(ctx, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Database connection failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Database connection successful\n")
|
||||
fmt.Printf("📊 Database name: %s\n", s.db.Name())
|
||||
|
||||
// Получаем список всех коллекций
|
||||
collections, err := s.db.ListCollectionNames(ctx, bson.M{})
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to list collections: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("📁 Available collections: %v\n", collections)
|
||||
|
||||
// Проверяем коллекцию users
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
// Подсчитываем количество документов
|
||||
count, err := collection.CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to count users: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("👥 Total users in database: %d\n", count)
|
||||
|
||||
if count > 0 {
|
||||
// Показываем всех пользователей
|
||||
cursor, err := collection.Find(ctx, bson.M{})
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to find users: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var users []bson.M
|
||||
if err := cursor.All(ctx, &users); err != nil {
|
||||
fmt.Printf("❌ Failed to decode users: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("📋 All users in database:\n")
|
||||
for i, user := range users {
|
||||
fmt.Printf(" %d. Email: %s, Name: %s, Verified: %v\n",
|
||||
i+1,
|
||||
user["email"],
|
||||
user["name"],
|
||||
user["verified"])
|
||||
}
|
||||
|
||||
// Тестируем поиск конкретного пользователя
|
||||
fmt.Printf("\n🔍 Testing specific user search:\n")
|
||||
testEmails := []string{"neo.movies.mail@gmail.com", "fenixoffc@gmail.com", "test@example.com"}
|
||||
|
||||
for _, email := range testEmails {
|
||||
var user bson.M
|
||||
err := collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
|
||||
if err != nil {
|
||||
fmt.Printf(" ❌ User %s: NOT FOUND (%v)\n", email, err)
|
||||
} else {
|
||||
fmt.Printf(" ✅ User %s: FOUND (Name: %s, Verified: %v)\n",
|
||||
email,
|
||||
user["name"],
|
||||
user["verified"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("=== END DATABASE TEST ===")
|
||||
}
|
||||
|
||||
// Генерация 6-значного кода
|
||||
func (s *AuthService) generateVerificationCode() string {
|
||||
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
// Проверяем, не существует ли уже пользователь с таким email
|
||||
var existingUser models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser)
|
||||
if err == nil {
|
||||
return nil, errors.New("email already registered")
|
||||
}
|
||||
|
||||
// Хешируем пароль
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Генерируем код верификации
|
||||
code := s.generateVerificationCode()
|
||||
codeExpires := time.Now().Add(10 * time.Minute) // 10 минут
|
||||
|
||||
// Создаем нового пользователя (НЕ ВЕРИФИЦИРОВАННОГО)
|
||||
user := models.User{
|
||||
ID: primitive.NewObjectID(),
|
||||
Email: req.Email,
|
||||
Password: string(hashedPassword),
|
||||
Name: req.Name,
|
||||
Favorites: []string{},
|
||||
Verified: false,
|
||||
VerificationCode: code,
|
||||
VerificationExpires: codeExpires,
|
||||
IsAdmin: false,
|
||||
AdminVerified: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err = collection.InsertOne(context.Background(), user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Отправляем код верификации на email
|
||||
if s.emailService != nil {
|
||||
go s.emailService.SendVerificationEmail(user.Email, code)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Registered. Check email for verification code.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
fmt.Printf("🔍 Login attempt for email: %s\n", req.Email)
|
||||
fmt.Printf("📊 Database name: %s\n", s.db.Name())
|
||||
fmt.Printf("📁 Collection name: %s\n", collection.Name())
|
||||
|
||||
// Находим пользователя по email (точно как в JavaScript)
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ User not found: %v\n", err)
|
||||
return nil, errors.New("User not found")
|
||||
}
|
||||
|
||||
// Проверяем верификацию email (точно как в JavaScript)
|
||||
if !user.Verified {
|
||||
return nil, errors.New("Account not activated. Please verify your email.")
|
||||
}
|
||||
|
||||
// Проверяем пароль (точно как в JavaScript)
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
|
||||
if err != nil {
|
||||
return nil, errors.New("Invalid password")
|
||||
}
|
||||
|
||||
// Генерируем JWT токен
|
||||
token, err := s.generateJWT(user.ID.Hex())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.AuthResponse{
|
||||
Token: token,
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user models.User
|
||||
err = collection.FindOne(context.Background(), bson.M{"_id": objectID}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updates["updated_at"] = time.Now()
|
||||
|
||||
_, err = collection.UpdateOne(
|
||||
context.Background(),
|
||||
bson.M{"_id": objectID},
|
||||
bson.M{"$set": updates},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetUserByID(userID)
|
||||
}
|
||||
|
||||
func (s *AuthService) generateJWT(userID string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"user_id": userID,
|
||||
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 дней
|
||||
"iat": time.Now().Unix(),
|
||||
"jti": uuid.New().String(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.jwtSecret))
|
||||
}
|
||||
|
||||
// Верификация email
|
||||
func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
if user.Verified {
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Email already verified",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Проверяем код и срок действия
|
||||
if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) {
|
||||
return nil, errors.New("invalid or expired verification code")
|
||||
}
|
||||
|
||||
// Верифицируем пользователя
|
||||
_, err = collection.UpdateOne(
|
||||
context.Background(),
|
||||
bson.M{"email": req.Email},
|
||||
bson.M{
|
||||
"$set": bson.M{"verified": true},
|
||||
"$unset": bson.M{
|
||||
"verificationCode": "",
|
||||
"verificationExpires": "",
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Email verified successfully",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Повторная отправка кода верификации
|
||||
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
if user.Verified {
|
||||
return nil, errors.New("email already verified")
|
||||
}
|
||||
|
||||
// Генерируем новый код
|
||||
code := s.generateVerificationCode()
|
||||
codeExpires := time.Now().Add(10 * time.Minute)
|
||||
|
||||
// Обновляем код в базе
|
||||
_, err = collection.UpdateOne(
|
||||
context.Background(),
|
||||
bson.M{"email": req.Email},
|
||||
bson.M{
|
||||
"$set": bson.M{
|
||||
"verificationCode": code,
|
||||
"verificationExpires": codeExpires,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Отправляем новый код на email
|
||||
if s.emailService != nil {
|
||||
go s.emailService.SendVerificationEmail(user.Email, code)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Verification code sent to your email",
|
||||
}, nil
|
||||
}
|
||||
150
pkg/services/email.go
Normal file
150
pkg/services/email.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"neomovies-api/pkg/config"
|
||||
)
|
||||
|
||||
type EmailService struct {
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func NewEmailService(cfg *config.Config) *EmailService {
|
||||
return &EmailService{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
type EmailOptions struct {
|
||||
To []string
|
||||
Subject string
|
||||
Body string
|
||||
IsHTML bool
|
||||
}
|
||||
|
||||
func (s *EmailService) SendEmail(options *EmailOptions) error {
|
||||
if s.config.GmailUser == "" || s.config.GmailPassword == "" {
|
||||
return fmt.Errorf("Gmail credentials not configured")
|
||||
}
|
||||
|
||||
// Gmail SMTP конфигурация
|
||||
smtpHost := "smtp.gmail.com"
|
||||
smtpPort := "587"
|
||||
auth := smtp.PlainAuth("", s.config.GmailUser, s.config.GmailPassword, smtpHost)
|
||||
|
||||
// Создаем заголовки email
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = s.config.GmailUser
|
||||
headers["To"] = strings.Join(options.To, ",")
|
||||
headers["Subject"] = options.Subject
|
||||
|
||||
if options.IsHTML {
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=UTF-8"
|
||||
}
|
||||
|
||||
// Формируем сообщение
|
||||
message := ""
|
||||
for key, value := range headers {
|
||||
message += fmt.Sprintf("%s: %s\r\n", key, value)
|
||||
}
|
||||
message += "\r\n" + options.Body
|
||||
|
||||
// Отправляем email
|
||||
err := smtp.SendMail(
|
||||
smtpHost+":"+smtpPort,
|
||||
auth,
|
||||
s.config.GmailUser,
|
||||
options.To,
|
||||
[]byte(message),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Предустановленные шаблоны email
|
||||
func (s *EmailService) SendVerificationEmail(userEmail, code string) error {
|
||||
options := &EmailOptions{
|
||||
To: []string{userEmail},
|
||||
Subject: "Подтверждение регистрации Neo Movies",
|
||||
Body: fmt.Sprintf(`
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h1 style="color: #2196f3;">Neo Movies</h1>
|
||||
<p>Здравствуйте!</p>
|
||||
<p>Для завершения регистрации введите этот код:</p>
|
||||
<div style="
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
letter-spacing: 4px;
|
||||
margin: 20px 0;
|
||||
">
|
||||
%s
|
||||
</div>
|
||||
<p>Код действителен в течение 10 минут.</p>
|
||||
<p>Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо.</p>
|
||||
</div>
|
||||
`, code),
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return s.SendEmail(options)
|
||||
}
|
||||
|
||||
func (s *EmailService) SendPasswordResetEmail(userEmail, resetToken string) error {
|
||||
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, resetToken)
|
||||
|
||||
options := &EmailOptions{
|
||||
To: []string{userEmail},
|
||||
Subject: "Сброс пароля Neo Movies",
|
||||
Body: fmt.Sprintf(`
|
||||
<html>
|
||||
<body>
|
||||
<h2>Сброс пароля</h2>
|
||||
<p>Вы запросили сброс пароля для вашего аккаунта Neo Movies.</p>
|
||||
<p>Нажмите на ссылку ниже, чтобы создать новый пароль:</p>
|
||||
<p><a href="%s">Сбросить пароль</a></p>
|
||||
<p>Ссылка действительна в течение 1 часа.</p>
|
||||
<p>Если вы не запрашивали сброс пароля, проигнорируйте это сообщение.</p>
|
||||
<br>
|
||||
<p>С уважением,<br>Команда Neo Movies</p>
|
||||
</body>
|
||||
</html>
|
||||
`, resetURL),
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return s.SendEmail(options)
|
||||
}
|
||||
|
||||
func (s *EmailService) SendMovieRecommendationEmail(userEmail, userName string, movies []string) error {
|
||||
moviesList := ""
|
||||
for _, movie := range movies {
|
||||
moviesList += fmt.Sprintf("<li>%s</li>", movie)
|
||||
}
|
||||
|
||||
options := &EmailOptions{
|
||||
To: []string{userEmail},
|
||||
Subject: "Новые рекомендации фильмов от Neo Movies",
|
||||
Body: fmt.Sprintf(`
|
||||
<html>
|
||||
<body>
|
||||
<h2>Привет, %s!</h2>
|
||||
<p>У нас есть новые рекомендации фильмов специально для вас:</p>
|
||||
<ul>%s</ul>
|
||||
<p>Заходите в приложение, чтобы узнать больше деталей!</p>
|
||||
<br>
|
||||
<p>С уважением,<br>Команда Neo Movies</p>
|
||||
</body>
|
||||
</html>
|
||||
`, userName, moviesList),
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return s.SendEmail(options)
|
||||
}
|
||||
110
pkg/services/movie.go
Normal file
110
pkg/services/movie.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type MovieService struct {
|
||||
db *mongo.Database
|
||||
tmdb *TMDBService
|
||||
}
|
||||
|
||||
func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService {
|
||||
return &MovieService{
|
||||
db: db,
|
||||
tmdb: tmdb,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MovieService) Search(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.SearchMovies(query, page, language, region, year)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetByID(id int, language string) (*models.Movie, error) {
|
||||
return s.tmdb.GetMovie(id, language)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetPopular(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetPopularMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetTopRated(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetTopRatedMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetUpcoming(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetUpcomingMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetNowPlaying(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetNowPlayingMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetMovieRecommendations(id, page, language)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBResponse, error) {
|
||||
return s.tmdb.GetSimilarMovies(id, page, language)
|
||||
}
|
||||
|
||||
func (s *MovieService) AddToFavorites(userID string, movieID string) error {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
filter := bson.M{"_id": userID}
|
||||
update := bson.M{
|
||||
"$addToSet": bson.M{"favorites": movieID},
|
||||
}
|
||||
|
||||
_, err := collection.UpdateOne(context.Background(), filter, update)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MovieService) RemoveFromFavorites(userID string, movieID string) error {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
filter := bson.M{"_id": userID}
|
||||
update := bson.M{
|
||||
"$pull": bson.M{"favorites": movieID},
|
||||
}
|
||||
|
||||
_, err := collection.UpdateOne(context.Background(), filter, update)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MovieService) GetFavorites(userID string, language string) ([]models.Movie, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var movies []models.Movie
|
||||
for _, movieIDStr := range user.Favorites {
|
||||
movieID, err := strconv.Atoi(movieIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
movie, err := s.tmdb.GetMovie(movieID, language)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
movies = append(movies, *movie)
|
||||
}
|
||||
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||
return s.tmdb.GetMovieExternalIDs(id)
|
||||
}
|
||||
212
pkg/services/reactions.go
Normal file
212
pkg/services/reactions.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type ReactionsService struct {
|
||||
db *mongo.Database
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewReactionsService(db *mongo.Database) *ReactionsService {
|
||||
return &ReactionsService{
|
||||
db: db,
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
const CUB_API_URL = "https://cub.rip/api"
|
||||
|
||||
var VALID_REACTIONS = []string{"fire", "nice", "think", "bore", "shit"}
|
||||
|
||||
// Получить счетчики реакций для медиа из внешнего API (cub.rip)
|
||||
func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) {
|
||||
cubID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", CUB_API_URL, cubID))
|
||||
if err != nil {
|
||||
return &models.ReactionCounts{}, nil // Возвращаем пустые счетчики при ошибке
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &models.ReactionCounts{}, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return &models.ReactionCounts{}, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Result []struct {
|
||||
Type string `json:"type"`
|
||||
Counter int `json:"counter"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return &models.ReactionCounts{}, nil
|
||||
}
|
||||
|
||||
// Преобразуем в нашу структуру
|
||||
counts := &models.ReactionCounts{}
|
||||
for _, reaction := range response.Result {
|
||||
switch reaction.Type {
|
||||
case "fire":
|
||||
counts.Fire = reaction.Counter
|
||||
case "nice":
|
||||
counts.Nice = reaction.Counter
|
||||
case "think":
|
||||
counts.Think = reaction.Counter
|
||||
case "bore":
|
||||
counts.Bore = reaction.Counter
|
||||
case "shit":
|
||||
counts.Shit = reaction.Counter
|
||||
}
|
||||
}
|
||||
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// Получить реакцию пользователя для медиа
|
||||
func (s *ReactionsService) GetUserReaction(userID, mediaType, mediaID string) (*models.Reaction, error) {
|
||||
collection := s.db.Collection("reactions")
|
||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
var reaction models.Reaction
|
||||
err := collection.FindOne(context.Background(), bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": fullMediaID,
|
||||
}).Decode(&reaction)
|
||||
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return nil, nil // Реакции нет
|
||||
}
|
||||
|
||||
return &reaction, err
|
||||
}
|
||||
|
||||
// Установить реакцию пользователя
|
||||
func (s *ReactionsService) SetUserReaction(userID, mediaType, mediaID, reactionType string) error {
|
||||
// Проверяем валидность типа реакции
|
||||
if !s.isValidReactionType(reactionType) {
|
||||
return fmt.Errorf("invalid reaction type: %s", reactionType)
|
||||
}
|
||||
|
||||
collection := s.db.Collection("reactions")
|
||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
// Создаем или обновляем реакцию
|
||||
filter := bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": fullMediaID,
|
||||
}
|
||||
|
||||
reaction := models.Reaction{
|
||||
UserID: userID,
|
||||
MediaID: fullMediaID,
|
||||
Type: reactionType,
|
||||
Created: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
update := bson.M{
|
||||
"$set": reaction,
|
||||
}
|
||||
|
||||
upsert := true
|
||||
_, err := collection.UpdateOne(context.Background(), filter, update, &options.UpdateOptions{
|
||||
Upsert: &upsert,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Отправляем реакцию в cub.rip API
|
||||
go s.sendReactionToCub(fullMediaID, reactionType)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Удалить реакцию пользователя
|
||||
func (s *ReactionsService) RemoveUserReaction(userID, mediaType, mediaID string) error {
|
||||
collection := s.db.Collection("reactions")
|
||||
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||
|
||||
_, err := collection.DeleteOne(context.Background(), bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": fullMediaID,
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Получить все реакции пользователя
|
||||
func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models.Reaction, error) {
|
||||
collection := s.db.Collection("reactions")
|
||||
|
||||
ctx := context.Background()
|
||||
cursor, err := collection.Find(ctx, bson.M{"userId": userID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(ctx)
|
||||
|
||||
var reactions []models.Reaction
|
||||
if err := cursor.All(ctx, &reactions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reactions, nil
|
||||
}
|
||||
|
||||
func (s *ReactionsService) isValidReactionType(reactionType string) bool {
|
||||
for _, valid := range VALID_REACTIONS {
|
||||
if valid == reactionType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Отправка реакции в cub.rip API (асинхронно)
|
||||
func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
|
||||
// Формируем запрос к cub.rip API
|
||||
url := fmt.Sprintf("%s/reactions/set", CUB_API_URL)
|
||||
|
||||
data := map[string]string{
|
||||
"mediaId": mediaID,
|
||||
"type": reactionType,
|
||||
}
|
||||
|
||||
_, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// В данном случае мы отправляем простой POST запрос
|
||||
// В будущем можно доработать для отправки JSON данных
|
||||
resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Логируем результат (в продакшене лучше использовать структурированное логирование)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType)
|
||||
}
|
||||
}
|
||||
479
pkg/services/tmdb.go
Normal file
479
pkg/services/tmdb.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type TMDBService struct {
|
||||
accessToken string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewTMDBService(accessToken string) *TMDBService {
|
||||
return &TMDBService{
|
||||
accessToken: accessToken,
|
||||
baseURL: "https://api.themoviedb.org/3",
|
||||
client: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TMDBService) makeRequest(endpoint string, target interface{}) error {
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Используем Bearer токен вместо API key в query параметрах
|
||||
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("TMDB API error: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchMovies(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
params.Set("include_adult", "false")
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
params.Set("region", region)
|
||||
}
|
||||
|
||||
if year > 0 {
|
||||
params.Set("year", strconv.Itoa(year))
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/search/movie?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchMulti(query string, page int, language string) (*models.MultiSearchResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
params.Set("include_adult", "false")
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/search/multi?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.MultiSearchResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Фильтруем результаты: убираем "person", и без названия
|
||||
filteredResults := make([]models.MultiSearchResult, 0)
|
||||
for _, result := range response.Results {
|
||||
if result.MediaType == "person" {
|
||||
continue
|
||||
}
|
||||
|
||||
hasTitle := false
|
||||
if result.MediaType == "movie" && result.Title != "" {
|
||||
hasTitle = true
|
||||
} else if result.MediaType == "tv" && result.Name != "" {
|
||||
hasTitle = true
|
||||
}
|
||||
|
||||
if hasTitle {
|
||||
filteredResults = append(filteredResults, result)
|
||||
}
|
||||
}
|
||||
|
||||
response.Results = filteredResults
|
||||
response.TotalResults = len(filteredResults)
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
params.Set("include_adult", "false")
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if firstAirDateYear > 0 {
|
||||
params.Set("first_air_date_year", strconv.Itoa(firstAirDateYear))
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/search/tv?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) {
|
||||
params := url.Values{}
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var movie models.Movie
|
||||
err := s.makeRequest(endpoint, &movie)
|
||||
return &movie, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) {
|
||||
params := url.Values{}
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var tvShow models.TVShow
|
||||
err := s.makeRequest(endpoint, &tvShow)
|
||||
return &tvShow, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) {
|
||||
params := url.Values{}
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/genre/%s/list?%s", s.baseURL, mediaType, params.Encode())
|
||||
|
||||
var response models.GenresResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) {
|
||||
// Получаем жанры фильмов
|
||||
movieGenres, err := s.GetGenres("movie", "ru-RU")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Получаем жанры сериалов
|
||||
tvGenres, err := s.GetGenres("tv", "ru-RU")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Объединяем жанры, убирая дубликаты
|
||||
allGenres := make(map[int]models.Genre)
|
||||
|
||||
for _, genre := range movieGenres.Genres {
|
||||
allGenres[genre.ID] = genre
|
||||
}
|
||||
|
||||
for _, genre := range tvGenres.Genres {
|
||||
allGenres[genre.ID] = genre
|
||||
}
|
||||
|
||||
// Преобразуем обратно в слайс
|
||||
var genres []models.Genre
|
||||
for _, genre := range allGenres {
|
||||
genres = append(genres, genre)
|
||||
}
|
||||
|
||||
return &models.GenresResponse{Genres: genres}, nil
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetPopularMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
params.Set("region", region)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/popular?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
params.Set("region", region)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/top_rated?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
params.Set("region", region)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/upcoming?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
if region != "" {
|
||||
params.Set("region", region)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/now_playing?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/%d/recommendations?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/movie/%d/similar?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/on_the_air?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/%d/recommendations?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/tv/%d/similar?%s", s.baseURL, id, params.Encode())
|
||||
|
||||
var response models.TMDBTVResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||
endpoint := fmt.Sprintf("%s/movie/%d/external_ids", s.baseURL, id)
|
||||
|
||||
var ids models.ExternalIDs
|
||||
err := s.makeRequest(endpoint, &ids)
|
||||
return &ids, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) GetTVExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||
endpoint := fmt.Sprintf("%s/tv/%d/external_ids", s.baseURL, id)
|
||||
|
||||
var ids models.ExternalIDs
|
||||
err := s.makeRequest(endpoint, &ids)
|
||||
return &ids, err
|
||||
}
|
||||
|
||||
func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) (*models.TMDBResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("page", strconv.Itoa(page))
|
||||
params.Set("with_genres", strconv.Itoa(genreID))
|
||||
params.Set("sort_by", "popularity.desc")
|
||||
|
||||
if language != "" {
|
||||
params.Set("language", language)
|
||||
} else {
|
||||
params.Set("language", "ru-RU")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/discover/movie?%s", s.baseURL, params.Encode())
|
||||
|
||||
var response models.TMDBResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
832
pkg/services/torrent.go
Normal file
832
pkg/services/torrent.go
Normal file
@@ -0,0 +1,832 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type TorrentService struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewTorrentService() *TorrentService {
|
||||
return &TorrentService{
|
||||
client: &http.Client{Timeout: 8 * time.Second},
|
||||
baseURL: "http://redapi.cfhttp.top",
|
||||
apiKey: "", // Может быть установлен через переменные окружения
|
||||
}
|
||||
}
|
||||
|
||||
// SearchTorrents - основной метод поиска торрентов через RedAPI
|
||||
func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) {
|
||||
searchParams := url.Values{}
|
||||
|
||||
// Добавляем все параметры поиска
|
||||
for key, value := range params {
|
||||
if value != "" {
|
||||
if key == "category" {
|
||||
searchParams.Add("category[]", value)
|
||||
} else {
|
||||
searchParams.Add(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if s.apiKey != "" {
|
||||
searchParams.Add("apikey", s.apiKey)
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s/api/v2.0/indexers/all/results?%s", s.baseURL, searchParams.Encode())
|
||||
|
||||
resp, err := s.client.Get(searchURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search torrents: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var redAPIResponse models.RedAPIResponse
|
||||
if err := json.Unmarshal(body, &redAPIResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
results := s.parseRedAPIResults(redAPIResponse)
|
||||
|
||||
return &models.TorrentSearchResponse{
|
||||
Query: params["query"],
|
||||
Results: results,
|
||||
Total: len(results),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseRedAPIResults преобразует результаты RedAPI в наш формат
|
||||
func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models.TorrentResult {
|
||||
var results []models.TorrentResult
|
||||
|
||||
for _, torrent := range data.Results {
|
||||
// Обрабатываем размер - может быть строкой или числом
|
||||
var sizeStr string
|
||||
switch v := torrent.Size.(type) {
|
||||
case string:
|
||||
sizeStr = v
|
||||
case float64:
|
||||
sizeStr = fmt.Sprintf("%.0f", v)
|
||||
case int:
|
||||
sizeStr = fmt.Sprintf("%d", v)
|
||||
default:
|
||||
sizeStr = ""
|
||||
}
|
||||
|
||||
result := models.TorrentResult{
|
||||
Title: torrent.Title,
|
||||
Tracker: torrent.Tracker,
|
||||
Size: sizeStr,
|
||||
Seeders: torrent.Seeders,
|
||||
Peers: torrent.Peers,
|
||||
MagnetLink: torrent.MagnetUri,
|
||||
PublishDate: torrent.PublishDate,
|
||||
Category: torrent.CategoryDesc,
|
||||
Details: torrent.Details,
|
||||
Source: "RedAPI",
|
||||
}
|
||||
|
||||
// Добавляем информацию из Info если она есть
|
||||
if torrent.Info != nil {
|
||||
// Обрабатываем качество - может быть строкой или числом
|
||||
switch v := torrent.Info.Quality.(type) {
|
||||
case string:
|
||||
result.Quality = v
|
||||
case float64:
|
||||
result.Quality = fmt.Sprintf("%.0fp", v)
|
||||
case int:
|
||||
result.Quality = fmt.Sprintf("%dp", v)
|
||||
}
|
||||
|
||||
result.Voice = torrent.Info.Voices
|
||||
result.Types = torrent.Info.Types
|
||||
result.Seasons = torrent.Info.Seasons
|
||||
}
|
||||
|
||||
// Если качество не определено через Info, пытаемся извлечь из названия
|
||||
if result.Quality == "" {
|
||||
result.Quality = s.ExtractQuality(result.Title)
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
|
||||
func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) {
|
||||
// Получаем информацию о фильме/сериале из TMDB
|
||||
title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get title from TMDB: %w", err)
|
||||
}
|
||||
|
||||
// Формируем параметры поиска
|
||||
params := make(map[string]string)
|
||||
params["imdb"] = imdbID
|
||||
params["title"] = title
|
||||
params["title_original"] = originalTitle
|
||||
params["year"] = year
|
||||
|
||||
// Устанавливаем тип контента и категорию
|
||||
switch mediaType {
|
||||
case "movie":
|
||||
params["is_serial"] = "1"
|
||||
params["category"] = "2000"
|
||||
case "tv", "series":
|
||||
params["is_serial"] = "2"
|
||||
params["category"] = "5000"
|
||||
case "anime":
|
||||
params["is_serial"] = "5"
|
||||
params["category"] = "5070"
|
||||
default:
|
||||
params["is_serial"] = "1"
|
||||
params["category"] = "2000"
|
||||
}
|
||||
|
||||
// Добавляем сезон если указан
|
||||
if options != nil && options.Season != nil {
|
||||
params["season"] = strconv.Itoa(*options.Season)
|
||||
}
|
||||
|
||||
// Выполняем поиск
|
||||
response, err := s.SearchTorrents(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Применяем фильтрацию
|
||||
if options != nil {
|
||||
response.Results = s.FilterByContentType(response.Results, options.ContentType)
|
||||
response.Results = s.FilterTorrents(response.Results, options)
|
||||
response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder)
|
||||
response.Total = len(response.Results)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// SearchMovies - поиск фильмов с дополнительной фильтрацией
|
||||
func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
|
||||
params := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"is_serial": "1",
|
||||
"category": "2000",
|
||||
}
|
||||
|
||||
response, err := s.SearchTorrents(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.Results = s.FilterByContentType(response.Results, "movie")
|
||||
response.Total = len(response.Results)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// SearchSeries - поиск сериалов с поддержкой fallback и фильтрации по сезону
|
||||
func (s *TorrentService) SearchSeries(title, originalTitle, year string, season *int) (*models.TorrentSearchResponse, error) {
|
||||
params := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"is_serial": "2",
|
||||
"category": "5000",
|
||||
}
|
||||
if season != nil {
|
||||
params["season"] = strconv.Itoa(*season)
|
||||
}
|
||||
|
||||
response, err := s.SearchTorrents(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Если указан сезон и результатов мало, делаем fallback-поиск без сезона и фильтруем на клиенте
|
||||
if season != nil && len(response.Results) < 5 {
|
||||
paramsNoSeason := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"is_serial": "2",
|
||||
"category": "5000",
|
||||
}
|
||||
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
|
||||
if err == nil {
|
||||
filtered := s.filterBySeason(fallbackResp.Results, *season)
|
||||
// Объединяем и убираем дубликаты по MagnetLink
|
||||
all := append(response.Results, filtered...)
|
||||
unique := make([]models.TorrentResult, 0, len(all))
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range all {
|
||||
if !seen[t.MagnetLink] {
|
||||
unique = append(unique, t)
|
||||
seen[t.MagnetLink] = true
|
||||
}
|
||||
}
|
||||
response.Results = unique
|
||||
}
|
||||
}
|
||||
|
||||
response.Results = s.FilterByContentType(response.Results, "serial")
|
||||
response.Total = len(response.Results)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// filterBySeason - фильтрация результатов по сезону (аналогично JS)
|
||||
func (s *TorrentService) filterBySeason(results []models.TorrentResult, season int) []models.TorrentResult {
|
||||
if season == 0 {
|
||||
return results
|
||||
}
|
||||
filtered := make([]models.TorrentResult, 0, len(results))
|
||||
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||
for _, torrent := range results {
|
||||
found := false
|
||||
// Проверяем поле seasons
|
||||
for _, s := range torrent.Seasons {
|
||||
if s == season {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
filtered = append(filtered, torrent)
|
||||
continue
|
||||
}
|
||||
// Проверяем в названии
|
||||
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber == season {
|
||||
filtered = append(filtered, torrent)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// SearchAnime - поиск аниме
|
||||
func (s *TorrentService) SearchAnime(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
|
||||
params := map[string]string{
|
||||
"title": title,
|
||||
"title_original": originalTitle,
|
||||
"year": year,
|
||||
"is_serial": "5",
|
||||
"category": "5070",
|
||||
}
|
||||
|
||||
response, err := s.SearchTorrents(params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.Results = s.FilterByContentType(response.Results, "anime")
|
||||
response.Total = len(response.Results)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// AllohaResponse - структура ответа от Alloha API
|
||||
type AllohaResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Year int `json:"year"`
|
||||
Category int `json:"category"` // 1-фильм, 2-сериал
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// getMovieInfoByIMDB - получение информации через Alloha API (как в JavaScript версии)
|
||||
func (s *TorrentService) getMovieInfoByIMDB(imdbID string) (string, string, string, error) {
|
||||
// Используем тот же токен что и в JavaScript версии
|
||||
endpoint := fmt.Sprintf("https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&imdb=%s", imdbID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
var allohaResponse AllohaResponse
|
||||
if err := json.Unmarshal(body, &allohaResponse); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if allohaResponse.Status != "success" {
|
||||
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
|
||||
}
|
||||
|
||||
title := allohaResponse.Data.Name
|
||||
originalTitle := allohaResponse.Data.OriginalName
|
||||
year := ""
|
||||
if allohaResponse.Data.Year > 0 {
|
||||
year = strconv.Itoa(allohaResponse.Data.Year)
|
||||
}
|
||||
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
// getTitleFromTMDB - получение информации из TMDB (с fallback на Alloha API)
|
||||
func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, mediaType string) (string, string, string, error) {
|
||||
// Сначала пробуем Alloha API (как в JavaScript версии)
|
||||
title, originalTitle, year, err := s.getMovieInfoByIMDB(imdbID)
|
||||
if err == nil {
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
// Если Alloha API не работает, пробуем TMDB API
|
||||
endpoint := fmt.Sprintf("https://api.themoviedb.org/3/find/%s", imdbID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("external_source", "imdb_id")
|
||||
params.Set("language", "ru-RU")
|
||||
req.URL.RawQuery = params.Encode()
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tmdbService.accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
var findResponse struct {
|
||||
MovieResults []struct {
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
} `json:"movie_results"`
|
||||
TVResults []struct {
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
} `json:"tv_results"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &findResponse); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if mediaType == "movie" && len(findResponse.MovieResults) > 0 {
|
||||
movie := findResponse.MovieResults[0]
|
||||
title := movie.Title
|
||||
originalTitle := movie.OriginalTitle
|
||||
year := ""
|
||||
if movie.ReleaseDate != "" {
|
||||
year = movie.ReleaseDate[:4]
|
||||
}
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
if (mediaType == "tv" || mediaType == "series") && len(findResponse.TVResults) > 0 {
|
||||
tv := findResponse.TVResults[0]
|
||||
title := tv.Name
|
||||
originalTitle := tv.OriginalName
|
||||
year := ""
|
||||
if tv.FirstAirDate != "" {
|
||||
year = tv.FirstAirDate[:4]
|
||||
}
|
||||
return title, originalTitle, year, nil
|
||||
}
|
||||
|
||||
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
|
||||
}
|
||||
|
||||
// FilterByContentType - фильтрация по типу контента
|
||||
func (s *TorrentService) FilterByContentType(results []models.TorrentResult, contentType string) []models.TorrentResult {
|
||||
if contentType == "" {
|
||||
return results
|
||||
}
|
||||
|
||||
var filtered []models.TorrentResult
|
||||
|
||||
for _, torrent := range results {
|
||||
// Фильтрация по полю types, если оно есть
|
||||
if len(torrent.Types) > 0 {
|
||||
switch contentType {
|
||||
case "movie":
|
||||
if s.containsAny(torrent.Types, []string{"movie", "multfilm", "documovie"}) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "serial":
|
||||
if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "anime":
|
||||
if s.contains(torrent.Types, "anime") {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Фильтрация по названию, если types недоступно
|
||||
title := strings.ToLower(torrent.Title)
|
||||
switch contentType {
|
||||
case "movie":
|
||||
if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "serial":
|
||||
if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
case "anime":
|
||||
if torrent.Category == "TV/Anime" || regexp.MustCompile(`(?i)anime`).MatchString(title) {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
default:
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// FilterTorrents - фильтрация торрентов по опциям
|
||||
func (s *TorrentService) FilterTorrents(torrents []models.TorrentResult, options *models.TorrentSearchOptions) []models.TorrentResult {
|
||||
if options == nil {
|
||||
return torrents
|
||||
}
|
||||
|
||||
var filtered []models.TorrentResult
|
||||
|
||||
for _, torrent := range torrents {
|
||||
// Фильтрация по качеству
|
||||
if len(options.Quality) > 0 {
|
||||
found := false
|
||||
for _, quality := range options.Quality {
|
||||
if strings.EqualFold(torrent.Quality, quality) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по минимальному качеству
|
||||
if options.MinQuality != "" && !s.qualityMeetsMinimum(torrent.Quality, options.MinQuality) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Фильтрация по максимальному качеству
|
||||
if options.MaxQuality != "" && !s.qualityMeetsMaximum(torrent.Quality, options.MaxQuality) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Исключение качеств
|
||||
if len(options.ExcludeQualities) > 0 {
|
||||
excluded := false
|
||||
for _, excludeQuality := range options.ExcludeQualities {
|
||||
if strings.EqualFold(torrent.Quality, excludeQuality) {
|
||||
excluded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if excluded {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по HDR
|
||||
if options.HDR != nil {
|
||||
hasHDR := regexp.MustCompile(`(?i)(hdr|dolby.vision|dv)`).MatchString(torrent.Title)
|
||||
if *options.HDR != hasHDR {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по HEVC
|
||||
if options.HEVC != nil {
|
||||
hasHEVC := regexp.MustCompile(`(?i)(hevc|h\.265|x265)`).MatchString(torrent.Title)
|
||||
if *options.HEVC != hasHEVC {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Фильтрация по сезону (дополнительная на клиенте)
|
||||
if options.Season != nil {
|
||||
if !s.matchesSeason(torrent, *options.Season) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// matchesSeason - проверка соответствия сезону
|
||||
func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int) bool {
|
||||
// Проверяем в поле seasons
|
||||
for _, s := range torrent.Seasons {
|
||||
if s == season {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем в названии
|
||||
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber == season {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractQuality - извлечение качества из названия
|
||||
func (s *TorrentService) ExtractQuality(title string) string {
|
||||
title = strings.ToUpper(title)
|
||||
|
||||
qualityPatterns := []struct {
|
||||
pattern string
|
||||
quality string
|
||||
}{
|
||||
{`2160P|4K`, "2160p"},
|
||||
{`1440P`, "1440p"},
|
||||
{`1080P`, "1080p"},
|
||||
{`720P`, "720p"},
|
||||
{`480P`, "480p"},
|
||||
{`360P`, "360p"},
|
||||
}
|
||||
|
||||
for _, qp := range qualityPatterns {
|
||||
if matched, _ := regexp.MatchString(qp.pattern, title); matched {
|
||||
if qp.quality == "2160p" {
|
||||
return "4K"
|
||||
}
|
||||
return qp.quality
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// sortTorrents - сортировка результатов
|
||||
func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, sortOrder string) []models.TorrentResult {
|
||||
if sortBy == "" {
|
||||
sortBy = "seeders"
|
||||
}
|
||||
if sortOrder == "" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
|
||||
sort.Slice(torrents, func(i, j int) bool {
|
||||
var less bool
|
||||
|
||||
switch sortBy {
|
||||
case "seeders":
|
||||
less = torrents[i].Seeders < torrents[j].Seeders
|
||||
case "size":
|
||||
less = s.compareSizes(torrents[i].Size, torrents[j].Size)
|
||||
case "date":
|
||||
less = torrents[i].PublishDate < torrents[j].PublishDate
|
||||
default:
|
||||
less = torrents[i].Seeders < torrents[j].Seeders
|
||||
}
|
||||
|
||||
if sortOrder == "asc" {
|
||||
return less
|
||||
}
|
||||
return !less
|
||||
})
|
||||
|
||||
return torrents
|
||||
}
|
||||
|
||||
// GroupByQuality - группировка по качеству
|
||||
func (s *TorrentService) GroupByQuality(results []models.TorrentResult) map[string][]models.TorrentResult {
|
||||
groups := make(map[string][]models.TorrentResult)
|
||||
|
||||
for _, torrent := range results {
|
||||
quality := torrent.Quality
|
||||
if quality == "" {
|
||||
quality = "unknown"
|
||||
}
|
||||
|
||||
// Объединяем 4K и 2160p в одну группу
|
||||
if quality == "2160p" {
|
||||
quality = "4K"
|
||||
}
|
||||
|
||||
groups[quality] = append(groups[quality], torrent)
|
||||
}
|
||||
|
||||
// Сортируем торренты внутри каждой группы по сидам
|
||||
for quality := range groups {
|
||||
sort.Slice(groups[quality], func(i, j int) bool {
|
||||
return groups[quality][i].Seeders > groups[quality][j].Seeders
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// GroupBySeason - группировка по сезонам
|
||||
func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[string][]models.TorrentResult {
|
||||
groups := make(map[string][]models.TorrentResult)
|
||||
|
||||
for _, torrent := range results {
|
||||
seasons := make(map[int]bool)
|
||||
|
||||
// Извлекаем сезоны из поля seasons
|
||||
for _, season := range torrent.Seasons {
|
||||
seasons[season] = true
|
||||
}
|
||||
|
||||
// Извлекаем сезоны из названия
|
||||
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber > 0 {
|
||||
seasons[seasonNumber] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Если сезоны не найдены, добавляем в группу "unknown"
|
||||
if len(seasons) == 0 {
|
||||
groups["Неизвестно"] = append(groups["Неизвестно"], torrent)
|
||||
} else {
|
||||
// Добавляем торрент во все соответствующие группы сезонов
|
||||
for season := range seasons {
|
||||
seasonKey := fmt.Sprintf("Сезон %d", season)
|
||||
// Проверяем дубликаты
|
||||
found := false
|
||||
for _, existing := range groups[seasonKey] {
|
||||
if existing.MagnetLink == torrent.MagnetLink {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
groups[seasonKey] = append(groups[seasonKey], torrent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем торренты внутри каждой группы по сидам
|
||||
for season := range groups {
|
||||
sort.Slice(groups[season], func(i, j int) bool {
|
||||
return groups[season][i].Seeders > groups[season][j].Seeders
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// GetAvailableSeasons - получение доступных сезонов для сериала
|
||||
func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string) ([]int, error) {
|
||||
response, err := s.SearchSeries(title, originalTitle, year, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seasonsSet := make(map[int]bool)
|
||||
|
||||
for _, torrent := range response.Results {
|
||||
// Извлекаем из поля seasons
|
||||
for _, season := range torrent.Seasons {
|
||||
seasonsSet[season] = true
|
||||
}
|
||||
|
||||
// Извлекаем из названия
|
||||
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||
for _, match := range matches {
|
||||
seasonNumber := 0
|
||||
if match[1] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[1])
|
||||
} else if match[2] != "" {
|
||||
seasonNumber, _ = strconv.Atoi(match[2])
|
||||
}
|
||||
if seasonNumber > 0 {
|
||||
seasonsSet[seasonNumber] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var seasons []int
|
||||
for season := range seasonsSet {
|
||||
seasons = append(seasons, season)
|
||||
}
|
||||
|
||||
sort.Ints(seasons)
|
||||
return seasons, nil
|
||||
}
|
||||
|
||||
// Вспомогательные функции
|
||||
func (s *TorrentService) qualityMeetsMinimum(quality, minQuality string) bool {
|
||||
qualityOrder := map[string]int{
|
||||
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
|
||||
}
|
||||
|
||||
currentLevel := qualityOrder[strings.ToLower(quality)]
|
||||
minLevel := qualityOrder[strings.ToLower(minQuality)]
|
||||
|
||||
return currentLevel >= minLevel
|
||||
}
|
||||
|
||||
func (s *TorrentService) qualityMeetsMaximum(quality, maxQuality string) bool {
|
||||
qualityOrder := map[string]int{
|
||||
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
|
||||
}
|
||||
|
||||
currentLevel := qualityOrder[strings.ToLower(quality)]
|
||||
maxLevel := qualityOrder[strings.ToLower(maxQuality)]
|
||||
|
||||
return currentLevel <= maxLevel
|
||||
}
|
||||
|
||||
func (s *TorrentService) compareSizes(size1, size2 string) bool {
|
||||
// Простое сравнение размеров (можно улучшить)
|
||||
return len(size1) < len(size2)
|
||||
}
|
||||
|
||||
func (s *TorrentService) contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *TorrentService) containsAny(slice []string, items []string) bool {
|
||||
for _, item := range items {
|
||||
if s.contains(slice, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
55
pkg/services/tv.go
Normal file
55
pkg/services/tv.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type TVService struct {
|
||||
db *mongo.Database
|
||||
tmdb *TMDBService
|
||||
}
|
||||
|
||||
func NewTVService(db *mongo.Database, tmdb *TMDBService) *TVService {
|
||||
return &TVService{
|
||||
db: db,
|
||||
tmdb: tmdb,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TVService) Search(query string, page int, language string, year int) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.SearchTVShows(query, page, language, year)
|
||||
}
|
||||
|
||||
func (s *TVService) GetByID(id int, language string) (*models.TVShow, error) {
|
||||
return s.tmdb.GetTVShow(id, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetPopular(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetPopularTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetTopRated(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetTopRatedTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetOnTheAir(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetOnTheAirTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetAiringToday(page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetAiringTodayTVShows(page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetTVRecommendations(id, page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||
return s.tmdb.GetSimilarTVShows(id, page, language)
|
||||
}
|
||||
|
||||
func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||
return s.tmdb.GetTVExternalIDs(id)
|
||||
}
|
||||
Reference in New Issue
Block a user