Rewrite api to Go

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

62
pkg/config/config.go Normal file
View File

@@ -0,0 +1,62 @@
package config
import (
"log"
"os"
)
type Config struct {
MongoURI string
TMDBAccessToken string
JWTSecret string
Port string
BaseURL string
NodeEnv string
GmailUser string
GmailPassword string
LumexURL string
AllohaToken string
}
func New() *Config {
// Добавляем отладочное логирование для Vercel
mongoURI := getMongoURI()
log.Printf("DEBUG: MongoDB URI configured (length: %d)", len(mongoURI))
return &Config{
MongoURI: mongoURI,
TMDBAccessToken: getEnv("TMDB_ACCESS_TOKEN", ""),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"),
Port: getEnv("PORT", "3000"),
BaseURL: getEnv("BASE_URL", "http://localhost:3000"),
NodeEnv: getEnv("NODE_ENV", "development"),
GmailUser: getEnv("GMAIL_USER", ""),
GmailPassword: getEnv("GMAIL_APP_PASSWORD", ""),
LumexURL: getEnv("LUMEX_URL", ""),
AllohaToken: getEnv("ALLOHA_TOKEN", ""),
}
}
// getMongoURI проверяет различные варианты названий переменных для MongoDB URI
func getMongoURI() string {
// Проверяем различные возможные названия переменных
envVars := []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"}
for _, envVar := range envVars {
if value := os.Getenv(envVar); value != "" {
log.Printf("DEBUG: Using %s for MongoDB connection", envVar)
return value
}
}
// Если ни одна переменная не найдена, возвращаем пустую строку
log.Printf("DEBUG: No MongoDB URI environment variable found")
return ""
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View File

@@ -0,0 +1,45 @@
package database
import (
"context"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var client *mongo.Client
func Connect(uri string) (*mongo.Database, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var err error
client, err = mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
return nil, err
}
// Проверяем соединение
err = client.Ping(ctx, nil)
if err != nil {
return nil, err
}
return client.Database("database"), nil
}
func Disconnect() error {
if client == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return client.Disconnect(ctx)
}
func GetClient() *mongo.Client {
return client
}

159
pkg/handlers/auth.go Normal file
View File

@@ -0,0 +1,159 @@
package handlers
import (
"encoding/json"
"net/http"
"go.mongodb.org/mongo-driver/bson"
"neomovies-api/pkg/middleware"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type AuthHandler struct {
authService *services.AuthService
}
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
return &AuthHandler{
authService: authService,
}
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req models.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
response, err := h.authService.Register(req)
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
Message: "User registered successfully",
})
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req models.LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
response, err := h.authService.Login(req)
if err != nil {
// Определяем правильный статус код в зависимости от ошибки
statusCode := http.StatusBadRequest
if err.Error() == "Account not activated. Please verify your email." {
statusCode = http.StatusForbidden // 403 для неверифицированного email
}
http.Error(w, err.Error(), statusCode)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
Message: "Login successful",
})
}
func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
user, err := h.authService.GetUserByID(userID)
if err != nil {
http.Error(w, "User not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: user,
})
}
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
var updates map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Удаляем поля, которые нельзя обновлять через этот эндпоинт
delete(updates, "password")
delete(updates, "email")
delete(updates, "_id")
delete(updates, "created_at")
user, err := h.authService.UpdateUser(userID, bson.M(updates))
if err != nil {
http.Error(w, "Failed to update user", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: user,
Message: "Profile updated successfully",
})
}
// Верификация email
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
var req models.VerifyEmailRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
response, err := h.authService.VerifyEmail(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// Повторная отправка кода верификации
func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) {
var req models.ResendCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
response, err := h.authService.ResendVerificationCode(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}

View File

@@ -0,0 +1,96 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type CategoriesHandler struct {
tmdbService *services.TMDBService
}
func NewCategoriesHandler(tmdbService *services.TMDBService) *CategoriesHandler {
return &CategoriesHandler{
tmdbService: tmdbService,
}
}
type Category struct {
ID int `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
func (h *CategoriesHandler) GetCategories(w http.ResponseWriter, r *http.Request) {
// Получаем все жанры
genresResponse, err := h.tmdbService.GetAllGenres()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Преобразуем жанры в категории
var categories []Category
for _, genre := range genresResponse.Genres {
slug := generateSlug(genre.Name)
categories = append(categories, Category{
ID: genre.ID,
Name: genre.Name,
Slug: slug,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: categories,
})
}
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
categoryID, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid category ID", http.StatusBadRequest)
return
}
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
if language == "" {
language = "ru-RU"
}
// Используем discover API для получения фильмов по жанру
movies, err := h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
})
}
func generateSlug(name string) string {
// Простая функция для создания slug из названия
// В реальном проекте стоит использовать более сложную логику
result := ""
for _, char := range name {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
result += string(char)
} else if char == ' ' {
result += "-"
}
}
return result
}

1332
pkg/handlers/docs.go Normal file

File diff suppressed because it is too large Load Diff

29
pkg/handlers/health.go Normal file
View File

@@ -0,0 +1,29 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"neomovies-api/pkg/models"
)
func HealthCheck(w http.ResponseWriter, r *http.Request) {
health := map[string]interface{}{
"status": "OK",
"timestamp": time.Now().UTC(),
"service": "neomovies-api",
"version": "2.0.0",
"uptime": time.Since(startTime),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "API is running",
Data: health,
})
}
var startTime = time.Now()

147
pkg/handlers/images.go Normal file
View File

@@ -0,0 +1,147 @@
package handlers
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gorilla/mux"
)
type ImagesHandler struct{}
func NewImagesHandler() *ImagesHandler {
return &ImagesHandler{}
}
const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
size := vars["size"]
imagePath := vars["path"]
if size == "" || imagePath == "" {
http.Error(w, "Size and path are required", http.StatusBadRequest)
return
}
// Если запрашивается placeholder, возвращаем локальный файл
if imagePath == "placeholder.jpg" {
h.servePlaceholder(w, r)
return
}
// Проверяем размер изображения
validSizes := []string{"w92", "w154", "w185", "w342", "w500", "w780", "original"}
if !h.isValidSize(size, validSizes) {
size = "original"
}
// Формируем URL изображения
imageURL := fmt.Sprintf("%s/%s/%s", TMDB_IMAGE_BASE_URL, size, imagePath)
// Получаем изображение
resp, err := http.Get(imageURL)
if err != nil {
h.servePlaceholder(w, r)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
h.servePlaceholder(w, r)
return
}
// Устанавливаем заголовки
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.Header().Set("Cache-Control", "public, max-age=31536000") // кэшируем на 1 год
// Передаем изображение клиенту
_, err = io.Copy(w, resp.Body)
if err != nil {
// Если ошибка при копировании, отдаем placeholder
h.servePlaceholder(w, r)
return
}
}
func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) {
// Попробуем найти placeholder изображение
placeholderPaths := []string{
"./assets/placeholder.jpg",
"./public/images/placeholder.jpg",
"./static/placeholder.jpg",
}
var placeholderPath string
for _, path := range placeholderPaths {
if _, err := os.Stat(path); err == nil {
placeholderPath = path
break
}
}
if placeholderPath == "" {
// Если placeholder не найден, создаем простую SVG заглушку
h.serveSVGPlaceholder(w, r)
return
}
file, err := os.Open(placeholderPath)
if err != nil {
h.serveSVGPlaceholder(w, r)
return
}
defer file.Close()
// Определяем content-type по расширению
ext := strings.ToLower(filepath.Ext(placeholderPath))
switch ext {
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
case ".webp":
w.Header().Set("Content-Type", "image/webp")
default:
w.Header().Set("Content-Type", "image/jpeg")
}
w.Header().Set("Cache-Control", "public, max-age=3600") // кэшируем placeholder на 1 час
_, err = io.Copy(w, file)
if err != nil {
h.serveSVGPlaceholder(w, r)
}
}
func (h *ImagesHandler) serveSVGPlaceholder(w http.ResponseWriter, r *http.Request) {
svgPlaceholder := `<svg width="300" height="450" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#f0f0f0"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" fill="#666">
Изображение не найдено
</text>
</svg>`
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write([]byte(svgPlaceholder))
}
func (h *ImagesHandler) isValidSize(size string, validSizes []string) bool {
for _, validSize := range validSizes {
if size == validSize {
return true
}
}
return false
}

294
pkg/handlers/movie.go Normal file
View File

@@ -0,0 +1,294 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"neomovies-api/pkg/middleware"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type MovieHandler struct {
movieService *services.MovieService
}
func NewMovieHandler(movieService *services.MovieService) *MovieHandler {
return &MovieHandler{
movieService: movieService,
}
}
func (h *MovieHandler) Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("query")
if query == "" {
http.Error(w, "Query parameter is required", http.StatusBadRequest)
return
}
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
region := r.URL.Query().Get("region")
year := getIntQuery(r, "year", 0)
movies, err := h.movieService.Search(query, page, language, region, year)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
})
}
func (h *MovieHandler) GetByID(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
return
}
language := r.URL.Query().Get("language")
movie, err := h.movieService.GetByID(id, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movie,
})
}
func (h *MovieHandler) Popular(w http.ResponseWriter, r *http.Request) {
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
region := r.URL.Query().Get("region")
movies, err := h.movieService.GetPopular(page, language, region)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
})
}
func (h *MovieHandler) TopRated(w http.ResponseWriter, r *http.Request) {
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
region := r.URL.Query().Get("region")
movies, err := h.movieService.GetTopRated(page, language, region)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
})
}
func (h *MovieHandler) Upcoming(w http.ResponseWriter, r *http.Request) {
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
region := r.URL.Query().Get("region")
movies, err := h.movieService.GetUpcoming(page, language, region)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
})
}
func (h *MovieHandler) NowPlaying(w http.ResponseWriter, r *http.Request) {
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
region := r.URL.Query().Get("region")
movies, err := h.movieService.GetNowPlaying(page, language, region)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
})
}
func (h *MovieHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
return
}
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
movies, err := h.movieService.GetRecommendations(id, page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
})
}
func (h *MovieHandler) GetSimilar(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
return
}
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
movies, err := h.movieService.GetSimilar(id, page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
})
}
func (h *MovieHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
language := r.URL.Query().Get("language")
movies, err := h.movieService.GetFavorites(userID, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: movies,
})
}
func (h *MovieHandler) AddToFavorites(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
movieID := vars["id"]
err := h.movieService.AddToFavorites(userID, movieID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Movie added to favorites",
})
}
func (h *MovieHandler) RemoveFromFavorites(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
movieID := vars["id"]
err := h.movieService.RemoveFromFavorites(userID, movieID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Movie removed from favorites",
})
}
func (h *MovieHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
return
}
externalIDs, err := h.movieService.GetExternalIDs(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: externalIDs,
})
}
func getIntQuery(r *http.Request, key string, defaultValue int) int {
str := r.URL.Query().Get(key)
if str == "" {
return defaultValue
}
value, err := strconv.Atoi(str)
if err != nil {
return defaultValue
}
return value
}

142
pkg/handlers/players.go Normal file
View File

@@ -0,0 +1,142 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"neomovies-api/pkg/config"
"github.com/gorilla/mux"
)
type PlayersHandler struct {
config *config.Config
}
func NewPlayersHandler(cfg *config.Config) *PlayersHandler {
return &PlayersHandler{
config: cfg,
}
}
func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) {
log.Printf("GetAllohaPlayer called: %s %s", r.Method, r.URL.Path)
vars := mux.Vars(r)
log.Printf("Route vars: %+v", vars)
imdbID := vars["imdb_id"]
if imdbID == "" {
log.Printf("Error: imdb_id is empty")
http.Error(w, "imdb_id path param is required", http.StatusBadRequest)
return
}
log.Printf("Processing imdb_id: %s", imdbID)
if h.config.AllohaToken == "" {
log.Printf("Error: ALLOHA_TOKEN is missing")
http.Error(w, "Server misconfiguration: ALLOHA_TOKEN missing", http.StatusInternalServerError)
return
}
idParam := fmt.Sprintf("imdb=%s", url.QueryEscape(imdbID))
apiURL := fmt.Sprintf("https://api.alloha.tv/?token=%s&%s", h.config.AllohaToken, idParam)
log.Printf("Calling Alloha API: %s", apiURL)
resp, err := http.Get(apiURL)
if err != nil {
log.Printf("Error calling Alloha API: %v", err)
http.Error(w, "Failed to fetch from Alloha API", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
log.Printf("Alloha API response status: %d", resp.StatusCode)
if resp.StatusCode != http.StatusOK {
http.Error(w, fmt.Sprintf("Alloha API error: %d", resp.StatusCode), http.StatusBadGateway)
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("Error reading Alloha response: %v", err)
http.Error(w, "Failed to read Alloha response", http.StatusInternalServerError)
return
}
log.Printf("Alloha API response body: %s", string(body))
var allohaResponse struct {
Status string `json:"status"`
Data struct {
Iframe string `json:"iframe"`
} `json:"data"`
}
if err := json.Unmarshal(body, &allohaResponse); err != nil {
log.Printf("Error unmarshaling JSON: %v", err)
http.Error(w, "Invalid JSON from Alloha", http.StatusBadGateway)
return
}
if allohaResponse.Status != "success" || allohaResponse.Data.Iframe == "" {
log.Printf("Video not found or empty iframe")
http.Error(w, "Video not found", http.StatusNotFound)
return
}
iframeCode := allohaResponse.Data.Iframe
if !strings.Contains(iframeCode, "<") {
iframeCode = fmt.Sprintf(`<iframe src="%s" allowfullscreen style="border:none;width:100%%;height:100%%"></iframe>`, iframeCode)
}
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Alloha Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframeCode)
// Авто-исправление экранированных кавычек
htmlDoc = strings.ReplaceAll(htmlDoc, `\"`, `"`)
htmlDoc = strings.ReplaceAll(htmlDoc, `\'`, `'`)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(htmlDoc))
log.Printf("Successfully served Alloha player for imdb_id: %s", imdbID)
}
func (h *PlayersHandler) GetLumexPlayer(w http.ResponseWriter, r *http.Request) {
log.Printf("GetLumexPlayer called: %s %s", r.Method, r.URL.Path)
vars := mux.Vars(r)
log.Printf("Route vars: %+v", vars)
imdbID := vars["imdb_id"]
if imdbID == "" {
log.Printf("Error: imdb_id is empty")
http.Error(w, "imdb_id path param is required", http.StatusBadRequest)
return
}
log.Printf("Processing imdb_id: %s", imdbID)
if h.config.LumexURL == "" {
log.Printf("Error: LUMEX_URL is missing")
http.Error(w, "Server misconfiguration: LUMEX_URL missing", http.StatusInternalServerError)
return
}
url := fmt.Sprintf("%s?imdb_id=%s", h.config.LumexURL, url.QueryEscape(imdbID))
log.Printf("Generated Lumex URL: %s", url)
iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, url)
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Lumex Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframe)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(htmlDoc))
log.Printf("Successfully served Lumex player for imdb_id: %s", imdbID)
}

171
pkg/handlers/reactions.go Normal file
View File

@@ -0,0 +1,171 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"neomovies-api/pkg/middleware"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type ReactionsHandler struct {
reactionsService *services.ReactionsService
}
func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler {
return &ReactionsHandler{
reactionsService: reactionsService,
}
}
// Получить счетчики реакций для медиа (публичный эндпоинт)
func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mediaType := vars["mediaType"]
mediaID := vars["mediaId"]
if mediaType == "" || mediaID == "" {
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
return
}
counts, err := h.reactionsService.GetReactionCounts(mediaType, mediaID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(counts)
}
// Получить реакцию текущего пользователя (требует авторизации)
func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
mediaType := vars["mediaType"]
mediaID := vars["mediaId"]
if mediaType == "" || mediaID == "" {
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
return
}
reaction, err := h.reactionsService.GetUserReaction(userID, mediaType, mediaID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if reaction == nil {
json.NewEncoder(w).Encode(map[string]interface{}{})
} else {
json.NewEncoder(w).Encode(reaction)
}
}
// Установить реакцию пользователя (требует авторизации)
func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
mediaType := vars["mediaType"]
mediaID := vars["mediaId"]
if mediaType == "" || mediaID == "" {
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
return
}
var request struct {
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if request.Type == "" {
http.Error(w, "Reaction type is required", http.StatusBadRequest)
return
}
err := h.reactionsService.SetUserReaction(userID, mediaType, mediaID, request.Type)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Reaction set successfully",
})
}
// Удалить реакцию пользователя (требует авторизации)
func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
mediaType := vars["mediaType"]
mediaID := vars["mediaId"]
if mediaType == "" || mediaID == "" {
http.Error(w, "Media type and ID are required", http.StatusBadRequest)
return
}
err := h.reactionsService.RemoveUserReaction(userID, mediaType, mediaID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Reaction removed successfully",
})
}
// Получить все реакции пользователя (требует авторизации)
func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
limit := getIntQuery(r, "limit", 50)
reactions, err := h.reactionsService.GetUserReactions(userID, limit)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: reactions,
})
}

45
pkg/handlers/search.go Normal file
View File

@@ -0,0 +1,45 @@
package handlers
import (
"encoding/json"
"net/http"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type SearchHandler struct {
tmdbService *services.TMDBService
}
func NewSearchHandler(tmdbService *services.TMDBService) *SearchHandler {
return &SearchHandler{
tmdbService: tmdbService,
}
}
func (h *SearchHandler) MultiSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("query")
if query == "" {
http.Error(w, "Query parameter is required", http.StatusBadRequest)
return
}
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
if language == "" {
language = "ru-RU"
}
results, err := h.tmdbService.SearchMulti(query, page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: results,
})
}

367
pkg/handlers/torrents.go Normal file
View File

@@ -0,0 +1,367 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type TorrentsHandler struct {
torrentService *services.TorrentService
tmdbService *services.TMDBService
}
func NewTorrentsHandler(torrentService *services.TorrentService, tmdbService *services.TMDBService) *TorrentsHandler {
return &TorrentsHandler{
torrentService: torrentService,
tmdbService: tmdbService,
}
}
// SearchTorrents - поиск торрентов по IMDB ID
func (h *TorrentsHandler) SearchTorrents(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
imdbID := vars["imdbId"]
if imdbID == "" {
http.Error(w, "IMDB ID is required", http.StatusBadRequest)
return
}
// Параметры запроса
mediaType := r.URL.Query().Get("type")
if mediaType == "" {
mediaType = "movie"
}
// Создаем опции поиска
options := &models.TorrentSearchOptions{
ContentType: mediaType,
}
// Качество
if quality := r.URL.Query().Get("quality"); quality != "" {
options.Quality = strings.Split(quality, ",")
}
// Минимальное и максимальное качество
options.MinQuality = r.URL.Query().Get("minQuality")
options.MaxQuality = r.URL.Query().Get("maxQuality")
// Исключаемые качества
if excludeQualities := r.URL.Query().Get("excludeQualities"); excludeQualities != "" {
options.ExcludeQualities = strings.Split(excludeQualities, ",")
}
// HDR
if hdr := r.URL.Query().Get("hdr"); hdr != "" {
if hdrBool, err := strconv.ParseBool(hdr); err == nil {
options.HDR = &hdrBool
}
}
// HEVC
if hevc := r.URL.Query().Get("hevc"); hevc != "" {
if hevcBool, err := strconv.ParseBool(hevc); err == nil {
options.HEVC = &hevcBool
}
}
// Сортировка
options.SortBy = r.URL.Query().Get("sortBy")
if options.SortBy == "" {
options.SortBy = "seeders"
}
options.SortOrder = r.URL.Query().Get("sortOrder")
if options.SortOrder == "" {
options.SortOrder = "desc"
}
// Группировка
if groupByQuality := r.URL.Query().Get("groupByQuality"); groupByQuality == "true" {
options.GroupByQuality = true
}
if groupBySeason := r.URL.Query().Get("groupBySeason"); groupBySeason == "true" {
options.GroupBySeason = true
}
// Сезон для сериалов
if season := r.URL.Query().Get("season"); season != "" {
if seasonInt, err := strconv.Atoi(season); err == nil {
options.Season = &seasonInt
}
}
// Поиск торрентов
results, err := h.torrentService.SearchTorrentsByIMDbID(h.tmdbService, imdbID, mediaType, options)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Формируем ответ с группировкой если необходимо
response := map[string]interface{}{
"imdbId": imdbID,
"type": mediaType,
"total": results.Total,
}
if options.Season != nil {
response["season"] = *options.Season
}
// Применяем группировку если запрошена
if options.GroupByQuality && options.GroupBySeason {
// Группируем сначала по сезонам, затем по качеству внутри каждого сезона
seasonGroups := h.torrentService.GroupBySeason(results.Results)
finalGroups := make(map[string]map[string][]models.TorrentResult)
for season, torrents := range seasonGroups {
qualityGroups := h.torrentService.GroupByQuality(torrents)
finalGroups[season] = qualityGroups
}
response["grouped"] = true
response["groups"] = finalGroups
} else if options.GroupByQuality {
groups := h.torrentService.GroupByQuality(results.Results)
response["grouped"] = true
response["groups"] = groups
} else if options.GroupBySeason {
groups := h.torrentService.GroupBySeason(results.Results)
response["grouped"] = true
response["groups"] = groups
} else {
response["grouped"] = false
response["results"] = results.Results
}
if len(results.Results) == 0 {
response["error"] = "No torrents found for this IMDB ID"
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(response)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
})
}
// SearchMovies - поиск фильмов по названию
func (h *TorrentsHandler) SearchMovies(w http.ResponseWriter, r *http.Request) {
title := r.URL.Query().Get("title")
originalTitle := r.URL.Query().Get("originalTitle")
year := r.URL.Query().Get("year")
if title == "" && originalTitle == "" {
http.Error(w, "Title or original title is required", http.StatusBadRequest)
return
}
results, err := h.torrentService.SearchMovies(title, originalTitle, year)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"title": title,
"originalTitle": originalTitle,
"year": year,
"type": "movie",
"total": results.Total,
"results": results.Results,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
})
}
// SearchSeries - поиск сериалов по названию с поддержкой сезонов
func (h *TorrentsHandler) SearchSeries(w http.ResponseWriter, r *http.Request) {
title := r.URL.Query().Get("title")
originalTitle := r.URL.Query().Get("originalTitle")
year := r.URL.Query().Get("year")
if title == "" && originalTitle == "" {
http.Error(w, "Title or original title is required", http.StatusBadRequest)
return
}
var season *int
if seasonStr := r.URL.Query().Get("season"); seasonStr != "" {
if seasonInt, err := strconv.Atoi(seasonStr); err == nil {
season = &seasonInt
}
}
results, err := h.torrentService.SearchSeries(title, originalTitle, year, season)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"title": title,
"originalTitle": originalTitle,
"year": year,
"type": "series",
"total": results.Total,
"results": results.Results,
}
if season != nil {
response["season"] = *season
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
})
}
// SearchAnime - поиск аниме по названию
func (h *TorrentsHandler) SearchAnime(w http.ResponseWriter, r *http.Request) {
title := r.URL.Query().Get("title")
originalTitle := r.URL.Query().Get("originalTitle")
year := r.URL.Query().Get("year")
if title == "" && originalTitle == "" {
http.Error(w, "Title or original title is required", http.StatusBadRequest)
return
}
results, err := h.torrentService.SearchAnime(title, originalTitle, year)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"title": title,
"originalTitle": originalTitle,
"year": year,
"type": "anime",
"total": results.Total,
"results": results.Results,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
})
}
// GetAvailableSeasons - получение доступных сезонов для сериала
func (h *TorrentsHandler) GetAvailableSeasons(w http.ResponseWriter, r *http.Request) {
title := r.URL.Query().Get("title")
originalTitle := r.URL.Query().Get("originalTitle")
year := r.URL.Query().Get("year")
if title == "" && originalTitle == "" {
http.Error(w, "Title or original title is required", http.StatusBadRequest)
return
}
seasons, err := h.torrentService.GetAvailableSeasons(title, originalTitle, year)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := map[string]interface{}{
"title": title,
"originalTitle": originalTitle,
"year": year,
"seasons": seasons,
"total": len(seasons),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
})
}
// SearchByQuery - универсальный поиск торрентов
func (h *TorrentsHandler) SearchByQuery(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("query")
if query == "" {
http.Error(w, "Query is required", http.StatusBadRequest)
return
}
contentType := r.URL.Query().Get("type")
if contentType == "" {
contentType = "movie"
}
year := r.URL.Query().Get("year")
// Формируем параметры поиска
params := map[string]string{
"query": query,
}
if year != "" {
params["year"] = year
}
// Устанавливаем тип контента и категорию
switch contentType {
case "movie":
params["is_serial"] = "1"
params["category"] = "2000"
case "series", "tv":
params["is_serial"] = "2"
params["category"] = "5000"
case "anime":
params["is_serial"] = "5"
params["category"] = "5070"
}
results, err := h.torrentService.SearchTorrents(params)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Применяем фильтрацию по типу контента
options := &models.TorrentSearchOptions{
ContentType: contentType,
}
results.Results = h.torrentService.FilterByContentType(results.Results, options.ContentType)
results.Total = len(results.Results)
response := map[string]interface{}{
"query": query,
"type": contentType,
"year": year,
"total": results.Total,
"results": results.Results,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: response,
})
}

206
pkg/handlers/tv.go Normal file
View File

@@ -0,0 +1,206 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type TVHandler struct {
tvService *services.TVService
}
func NewTVHandler(tvService *services.TVService) *TVHandler {
return &TVHandler{
tvService: tvService,
}
}
func (h *TVHandler) Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("query")
if query == "" {
http.Error(w, "Query parameter is required", http.StatusBadRequest)
return
}
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
year := getIntQuery(r, "first_air_date_year", 0)
tvShows, err := h.tvService.Search(query, page, language, year)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tvShows,
})
}
func (h *TVHandler) GetByID(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
return
}
language := r.URL.Query().Get("language")
tvShow, err := h.tvService.GetByID(id, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tvShow,
})
}
func (h *TVHandler) Popular(w http.ResponseWriter, r *http.Request) {
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
tvShows, err := h.tvService.GetPopular(page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tvShows,
})
}
func (h *TVHandler) TopRated(w http.ResponseWriter, r *http.Request) {
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
tvShows, err := h.tvService.GetTopRated(page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tvShows,
})
}
func (h *TVHandler) OnTheAir(w http.ResponseWriter, r *http.Request) {
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
tvShows, err := h.tvService.GetOnTheAir(page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tvShows,
})
}
func (h *TVHandler) AiringToday(w http.ResponseWriter, r *http.Request) {
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
tvShows, err := h.tvService.GetAiringToday(page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tvShows,
})
}
func (h *TVHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
return
}
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
tvShows, err := h.tvService.GetRecommendations(id, page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tvShows,
})
}
func (h *TVHandler) GetSimilar(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
return
}
page := getIntQuery(r, "page", 1)
language := r.URL.Query().Get("language")
tvShows, err := h.tvService.GetSimilar(id, page, language)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tvShows,
})
}
func (h *TVHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
return
}
externalIDs, err := h.tvService.GetExternalIDs(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: externalIDs,
})
}

63
pkg/middleware/auth.go Normal file
View File

@@ -0,0 +1,63 @@
package middleware
import (
"context"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const UserIDKey contextKey = "userID"
func JWTAuth(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
http.Error(w, "Bearer token required", http.StatusUnauthorized)
return
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
return
}
userID, ok := claims["user_id"].(string)
if !ok {
http.Error(w, "Invalid user ID in token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), UserIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetUserIDFromContext(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(UserIDKey).(string)
return userID, ok
}

292
pkg/models/movie.go Normal file
View File

@@ -0,0 +1,292 @@
package models
type Movie struct {
ID int `json:"id"`
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
GenreIDs []int `json:"genre_ids"`
Genres []Genre `json:"genres"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
Popularity float64 `json:"popularity"`
Adult bool `json:"adult"`
Video bool `json:"video"`
OriginalLanguage string `json:"original_language"`
Runtime int `json:"runtime,omitempty"`
Budget int64 `json:"budget,omitempty"`
Revenue int64 `json:"revenue,omitempty"`
Status string `json:"status,omitempty"`
Tagline string `json:"tagline,omitempty"`
Homepage string `json:"homepage,omitempty"`
IMDbID string `json:"imdb_id,omitempty"`
BelongsToCollection *Collection `json:"belongs_to_collection,omitempty"`
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"`
}
type TVShow struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
LastAirDate string `json:"last_air_date"`
GenreIDs []int `json:"genre_ids"`
Genres []Genre `json:"genres"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
Popularity float64 `json:"popularity"`
OriginalLanguage string `json:"original_language"`
OriginCountry []string `json:"origin_country"`
NumberOfEpisodes int `json:"number_of_episodes,omitempty"`
NumberOfSeasons int `json:"number_of_seasons,omitempty"`
Status string `json:"status,omitempty"`
Type string `json:"type,omitempty"`
Homepage string `json:"homepage,omitempty"`
InProduction bool `json:"in_production,omitempty"`
Languages []string `json:"languages,omitempty"`
Networks []Network `json:"networks,omitempty"`
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"`
CreatedBy []Creator `json:"created_by,omitempty"`
EpisodeRunTime []int `json:"episode_run_time,omitempty"`
Seasons []Season `json:"seasons,omitempty"`
}
// MultiSearchResult для мультипоиска
type MultiSearchResult struct {
ID int `json:"id"`
MediaType string `json:"media_type"` // "movie" или "tv"
Title string `json:"title,omitempty"` // для фильмов
Name string `json:"name,omitempty"` // для сериалов
OriginalTitle string `json:"original_title,omitempty"`
OriginalName string `json:"original_name,omitempty"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date,omitempty"` // для фильмов
FirstAirDate string `json:"first_air_date,omitempty"` // для сериалов
GenreIDs []int `json:"genre_ids"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
Popularity float64 `json:"popularity"`
Adult bool `json:"adult"`
OriginalLanguage string `json:"original_language"`
OriginCountry []string `json:"origin_country,omitempty"`
}
type MultiSearchResponse struct {
Page int `json:"page"`
Results []MultiSearchResult `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type Genre struct {
ID int `json:"id"`
Name string `json:"name"`
}
type GenresResponse struct {
Genres []Genre `json:"genres"`
}
type ExternalIDs struct {
ID int `json:"id"`
IMDbID string `json:"imdb_id"`
TVDBID int `json:"tvdb_id,omitempty"`
WikidataID string `json:"wikidata_id"`
FacebookID string `json:"facebook_id"`
InstagramID string `json:"instagram_id"`
TwitterID string `json:"twitter_id"`
}
type Collection struct {
ID int `json:"id"`
Name string `json:"name"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
}
type ProductionCompany struct {
ID int `json:"id"`
LogoPath string `json:"logo_path"`
Name string `json:"name"`
OriginCountry string `json:"origin_country"`
}
type ProductionCountry struct {
ISO31661 string `json:"iso_3166_1"`
Name string `json:"name"`
}
type SpokenLanguage struct {
EnglishName string `json:"english_name"`
ISO6391 string `json:"iso_639_1"`
Name string `json:"name"`
}
type Network struct {
ID int `json:"id"`
LogoPath string `json:"logo_path"`
Name string `json:"name"`
OriginCountry string `json:"origin_country"`
}
type Creator struct {
ID int `json:"id"`
CreditID string `json:"credit_id"`
Name string `json:"name"`
Gender int `json:"gender"`
ProfilePath string `json:"profile_path"`
}
type Season struct {
AirDate string `json:"air_date"`
EpisodeCount int `json:"episode_count"`
ID int `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
SeasonNumber int `json:"season_number"`
}
type TMDBResponse struct {
Page int `json:"page"`
Results []Movie `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBTVResponse struct {
Page int `json:"page"`
Results []TVShow `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type SearchParams struct {
Query string `json:"query"`
Page int `json:"page"`
Language string `json:"language"`
Region string `json:"region"`
Year int `json:"year"`
PrimaryReleaseYear int `json:"primary_release_year"`
}
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
// Модели для торрентов
type TorrentResult struct {
Title string `json:"title"`
Tracker string `json:"tracker"`
Size string `json:"size"`
Seeders int `json:"seeders"`
Peers int `json:"peers"`
Leechers int `json:"leechers"`
Quality string `json:"quality"`
Voice []string `json:"voice,omitempty"`
Types []string `json:"types,omitempty"`
Seasons []int `json:"seasons,omitempty"`
Category string `json:"category"`
MagnetLink string `json:"magnet"`
TorrentLink string `json:"torrent_link,omitempty"`
Details string `json:"details,omitempty"`
PublishDate string `json:"publish_date"`
AddedDate string `json:"added_date,omitempty"`
Source string `json:"source"`
}
type TorrentSearchResponse struct {
Query string `json:"query"`
Results []TorrentResult `json:"results"`
Total int `json:"total"`
}
// RedAPI специфичные структуры
type RedAPIResponse struct {
Results []RedAPITorrent `json:"Results"`
}
type RedAPITorrent struct {
Title string `json:"Title"`
Tracker string `json:"Tracker"`
Size interface{} `json:"Size"` // Может быть string или number
Seeders int `json:"Seeders"`
Peers int `json:"Peers"`
MagnetUri string `json:"MagnetUri"`
PublishDate string `json:"PublishDate"`
CategoryDesc string `json:"CategoryDesc"`
Details string `json:"Details"`
Info *RedAPITorrentInfo `json:"Info,omitempty"`
}
type RedAPITorrentInfo struct {
Quality interface{} `json:"quality,omitempty"` // Может быть string или number
Voices []string `json:"voices,omitempty"`
Types []string `json:"types,omitempty"`
Seasons []int `json:"seasons,omitempty"`
}
// Alloha API структуры для получения информации о фильмах
type AllohaResponse struct {
Data *AllohaData `json:"data"`
}
type AllohaData struct {
Name string `json:"name"`
OriginalName string `json:"original_name"`
}
// Опции поиска торрентов
type TorrentSearchOptions struct {
Season *int
Quality []string
MinQuality string
MaxQuality string
ExcludeQualities []string
HDR *bool
HEVC *bool
SortBy string
SortOrder string
GroupByQuality bool
GroupBySeason bool
ContentType string
}
// Модели для плееров
type PlayerResponse struct {
Type string `json:"type"`
URL string `json:"url"`
Iframe string `json:"iframe,omitempty"`
}
// Модели для реакций
type Reaction struct {
ID string `json:"id" bson:"_id,omitempty"`
UserID string `json:"userId" bson:"userId"`
MediaID string `json:"mediaId" bson:"mediaId"`
Type string `json:"type" bson:"type"`
Created string `json:"created" bson:"created"`
}
type ReactionCounts struct {
Fire int `json:"fire"`
Nice int `json:"nice"`
Think int `json:"think"`
Bore int `json:"bore"`
Shit int `json:"shit"`
}

48
pkg/models/user.go Normal file
View File

@@ -0,0 +1,48 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type User struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
Email string `json:"email" bson:"email" validate:"required,email"`
Password string `json:"-" bson:"password" validate:"required,min=6"`
Name string `json:"name" bson:"name" validate:"required"`
Avatar string `json:"avatar" bson:"avatar"`
Favorites []string `json:"favorites" bson:"favorites"`
Verified bool `json:"verified" bson:"verified"`
VerificationCode string `json:"-" bson:"verificationCode,omitempty"`
VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"`
IsAdmin bool `json:"isAdmin" bson:"isAdmin"`
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"`
}
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
Name string `json:"name" validate:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
User User `json:"user"`
}
type VerifyEmailRequest struct {
Email string `json:"email" validate:"required,email"`
Code string `json:"code" validate:"required"`
}
type ResendCodeRequest struct {
Email string `json:"email" validate:"required,email"`
}

91
pkg/monitor/monitor.go Normal file
View File

@@ -0,0 +1,91 @@
package monitor
import (
"fmt"
"net/http"
"strings"
"time"
)
// RequestMonitor создает middleware для мониторинга запросов в стиле htop
func RequestMonitor() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Создаем wrapper для ResponseWriter чтобы получить статус код
ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
// Выполняем запрос
next.ServeHTTP(ww, r)
// Вычисляем время выполнения
duration := time.Since(start)
// Форматируем URL (обрезаем если слишком длинный)
url := r.URL.Path
if r.URL.RawQuery != "" {
url += "?" + r.URL.RawQuery
}
if len(url) > 60 {
url = url[:57] + "..."
}
// Определяем цвет статуса
statusColor := getStatusColor(ww.statusCode)
methodColor := getMethodColor(r.Method)
// Выводим информацию о запросе
fmt.Printf("\033[2K\r%s%-6s\033[0m %s%-3d\033[0m │ %-60s │ %6.2fms\n",
methodColor, r.Method,
statusColor, ww.statusCode,
url,
float64(duration.Nanoseconds())/1000000,
)
})
}
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// getStatusColor возвращает ANSI цвет для статус кода
func getStatusColor(status int) string {
switch {
case status >= 200 && status < 300:
return "\033[32m" // Зеленый
case status >= 300 && status < 400:
return "\033[33m" // Желтый
case status >= 400 && status < 500:
return "\033[31m" // Красный
case status >= 500:
return "\033[35m" // Фиолетовый
default:
return "\033[37m" // Белый
}
}
// getMethodColor возвращает ANSI цвет для HTTP метода
func getMethodColor(method string) string {
switch strings.ToUpper(method) {
case "GET":
return "\033[34m" // Синий
case "POST":
return "\033[32m" // Зеленый
case "PUT":
return "\033[33m" // Желтый
case "DELETE":
return "\033[31m" // Красный
case "PATCH":
return "\033[36m" // Циан
default:
return "\033[37m" // Белый
}
}

353
pkg/services/auth.go Normal file
View File

@@ -0,0 +1,353 @@
package services
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"golang.org/x/crypto/bcrypt"
"neomovies-api/pkg/models"
)
type AuthService struct {
db *mongo.Database
jwtSecret string
emailService *EmailService
}
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService) *AuthService {
service := &AuthService{
db: db,
jwtSecret: jwtSecret,
emailService: emailService,
}
// Запускаем тест подключения к базе данных
go service.testDatabaseConnection()
return service
}
// testDatabaseConnection тестирует подключение к базе данных и выводит информацию о пользователях
func (s *AuthService) testDatabaseConnection() {
ctx := context.Background()
fmt.Println("=== DATABASE CONNECTION TEST ===")
// Проверяем подключение
err := s.db.Client().Ping(ctx, nil)
if err != nil {
fmt.Printf("❌ Database connection failed: %v\n", err)
return
}
fmt.Printf("✅ Database connection successful\n")
fmt.Printf("📊 Database name: %s\n", s.db.Name())
// Получаем список всех коллекций
collections, err := s.db.ListCollectionNames(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to list collections: %v\n", err)
return
}
fmt.Printf("📁 Available collections: %v\n", collections)
// Проверяем коллекцию users
collection := s.db.Collection("users")
// Подсчитываем количество документов
count, err := collection.CountDocuments(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to count users: %v\n", err)
return
}
fmt.Printf("👥 Total users in database: %d\n", count)
if count > 0 {
// Показываем всех пользователей
cursor, err := collection.Find(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to find users: %v\n", err)
return
}
defer cursor.Close(ctx)
var users []bson.M
if err := cursor.All(ctx, &users); err != nil {
fmt.Printf("❌ Failed to decode users: %v\n", err)
return
}
fmt.Printf("📋 All users in database:\n")
for i, user := range users {
fmt.Printf(" %d. Email: %s, Name: %s, Verified: %v\n",
i+1,
user["email"],
user["name"],
user["verified"])
}
// Тестируем поиск конкретного пользователя
fmt.Printf("\n🔍 Testing specific user search:\n")
testEmails := []string{"neo.movies.mail@gmail.com", "fenixoffc@gmail.com", "test@example.com"}
for _, email := range testEmails {
var user bson.M
err := collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
if err != nil {
fmt.Printf(" ❌ User %s: NOT FOUND (%v)\n", email, err)
} else {
fmt.Printf(" ✅ User %s: FOUND (Name: %s, Verified: %v)\n",
email,
user["name"],
user["verified"])
}
}
}
fmt.Println("=== END DATABASE TEST ===")
}
// Генерация 6-значного кода
func (s *AuthService) generateVerificationCode() string {
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
}
func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
// Проверяем, не существует ли уже пользователь с таким email
var existingUser models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser)
if err == nil {
return nil, errors.New("email already registered")
}
// Хешируем пароль
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Генерируем код верификации
code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute) // 10 минут
// Создаем нового пользователя (НЕ ВЕРИФИЦИРОВАННОГО)
user := models.User{
ID: primitive.NewObjectID(),
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Favorites: []string{},
Verified: false,
VerificationCode: code,
VerificationExpires: codeExpires,
IsAdmin: false,
AdminVerified: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = collection.InsertOne(context.Background(), user)
if err != nil {
return nil, err
}
// Отправляем код верификации на email
if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code)
}
return map[string]interface{}{
"success": true,
"message": "Registered. Check email for verification code.",
}, nil
}
func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) {
collection := s.db.Collection("users")
fmt.Printf("🔍 Login attempt for email: %s\n", req.Email)
fmt.Printf("📊 Database name: %s\n", s.db.Name())
fmt.Printf("📁 Collection name: %s\n", collection.Name())
// Находим пользователя по email (точно как в JavaScript)
var user models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
if err != nil {
fmt.Printf("❌ User not found: %v\n", err)
return nil, errors.New("User not found")
}
// Проверяем верификацию email (точно как в JavaScript)
if !user.Verified {
return nil, errors.New("Account not activated. Please verify your email.")
}
// Проверяем пароль (точно как в JavaScript)
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
if err != nil {
return nil, errors.New("Invalid password")
}
// Генерируем JWT токен
token, err := s.generateJWT(user.ID.Hex())
if err != nil {
return nil, err
}
return &models.AuthResponse{
Token: token,
User: user,
}, nil
}
func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
collection := s.db.Collection("users")
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return nil, err
}
var user models.User
err = collection.FindOne(context.Background(), bson.M{"_id": objectID}).Decode(&user)
if err != nil {
return nil, err
}
return &user, nil
}
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
collection := s.db.Collection("users")
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return nil, err
}
updates["updated_at"] = time.Now()
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": objectID},
bson.M{"$set": updates},
)
if err != nil {
return nil, err
}
return s.GetUserByID(userID)
}
func (s *AuthService) generateJWT(userID string) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 дней
"iat": time.Now().Unix(),
"jti": uuid.New().String(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.jwtSecret))
}
// Верификация email
func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
var user models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
if err != nil {
return nil, errors.New("user not found")
}
if user.Verified {
return map[string]interface{}{
"success": true,
"message": "Email already verified",
}, nil
}
// Проверяем код и срок действия
if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) {
return nil, errors.New("invalid or expired verification code")
}
// Верифицируем пользователя
_, err = collection.UpdateOne(
context.Background(),
bson.M{"email": req.Email},
bson.M{
"$set": bson.M{"verified": true},
"$unset": bson.M{
"verificationCode": "",
"verificationExpires": "",
},
},
)
if err != nil {
return nil, err
}
return map[string]interface{}{
"success": true,
"message": "Email verified successfully",
}, nil
}
// Повторная отправка кода верификации
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
var user models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
if err != nil {
return nil, errors.New("user not found")
}
if user.Verified {
return nil, errors.New("email already verified")
}
// Генерируем новый код
code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute)
// Обновляем код в базе
_, err = collection.UpdateOne(
context.Background(),
bson.M{"email": req.Email},
bson.M{
"$set": bson.M{
"verificationCode": code,
"verificationExpires": codeExpires,
},
},
)
if err != nil {
return nil, err
}
// Отправляем новый код на email
if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code)
}
return map[string]interface{}{
"success": true,
"message": "Verification code sent to your email",
}, nil
}

150
pkg/services/email.go Normal file
View File

@@ -0,0 +1,150 @@
package services
import (
"fmt"
"net/smtp"
"strings"
"neomovies-api/pkg/config"
)
type EmailService struct {
config *config.Config
}
func NewEmailService(cfg *config.Config) *EmailService {
return &EmailService{
config: cfg,
}
}
type EmailOptions struct {
To []string
Subject string
Body string
IsHTML bool
}
func (s *EmailService) SendEmail(options *EmailOptions) error {
if s.config.GmailUser == "" || s.config.GmailPassword == "" {
return fmt.Errorf("Gmail credentials not configured")
}
// Gmail SMTP конфигурация
smtpHost := "smtp.gmail.com"
smtpPort := "587"
auth := smtp.PlainAuth("", s.config.GmailUser, s.config.GmailPassword, smtpHost)
// Создаем заголовки email
headers := make(map[string]string)
headers["From"] = s.config.GmailUser
headers["To"] = strings.Join(options.To, ",")
headers["Subject"] = options.Subject
if options.IsHTML {
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=UTF-8"
}
// Формируем сообщение
message := ""
for key, value := range headers {
message += fmt.Sprintf("%s: %s\r\n", key, value)
}
message += "\r\n" + options.Body
// Отправляем email
err := smtp.SendMail(
smtpHost+":"+smtpPort,
auth,
s.config.GmailUser,
options.To,
[]byte(message),
)
return err
}
// Предустановленные шаблоны email
func (s *EmailService) SendVerificationEmail(userEmail, code string) error {
options := &EmailOptions{
To: []string{userEmail},
Subject: "Подтверждение регистрации Neo Movies",
Body: fmt.Sprintf(`
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #2196f3;">Neo Movies</h1>
<p>Здравствуйте!</p>
<p>Для завершения регистрации введите этот код:</p>
<div style="
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
text-align: center;
font-size: 24px;
letter-spacing: 4px;
margin: 20px 0;
">
%s
</div>
<p>Код действителен в течение 10 минут.</p>
<p>Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо.</p>
</div>
`, code),
IsHTML: true,
}
return s.SendEmail(options)
}
func (s *EmailService) SendPasswordResetEmail(userEmail, resetToken string) error {
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, resetToken)
options := &EmailOptions{
To: []string{userEmail},
Subject: "Сброс пароля Neo Movies",
Body: fmt.Sprintf(`
<html>
<body>
<h2>Сброс пароля</h2>
<p>Вы запросили сброс пароля для вашего аккаунта Neo Movies.</p>
<p>Нажмите на ссылку ниже, чтобы создать новый пароль:</p>
<p><a href="%s">Сбросить пароль</a></p>
<p>Ссылка действительна в течение 1 часа.</p>
<p>Если вы не запрашивали сброс пароля, проигнорируйте это сообщение.</p>
<br>
<p>С уважением,<br>Команда Neo Movies</p>
</body>
</html>
`, resetURL),
IsHTML: true,
}
return s.SendEmail(options)
}
func (s *EmailService) SendMovieRecommendationEmail(userEmail, userName string, movies []string) error {
moviesList := ""
for _, movie := range movies {
moviesList += fmt.Sprintf("<li>%s</li>", movie)
}
options := &EmailOptions{
To: []string{userEmail},
Subject: "Новые рекомендации фильмов от Neo Movies",
Body: fmt.Sprintf(`
<html>
<body>
<h2>Привет, %s!</h2>
<p>У нас есть новые рекомендации фильмов специально для вас:</p>
<ul>%s</ul>
<p>Заходите в приложение, чтобы узнать больше деталей!</p>
<br>
<p>С уважением,<br>Команда Neo Movies</p>
</body>
</html>
`, userName, moviesList),
IsHTML: true,
}
return s.SendEmail(options)
}

110
pkg/services/movie.go Normal file
View File

@@ -0,0 +1,110 @@
package services
import (
"context"
"strconv"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models"
)
type MovieService struct {
db *mongo.Database
tmdb *TMDBService
}
func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService {
return &MovieService{
db: db,
tmdb: tmdb,
}
}
func (s *MovieService) Search(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
return s.tmdb.SearchMovies(query, page, language, region, year)
}
func (s *MovieService) GetByID(id int, language string) (*models.Movie, error) {
return s.tmdb.GetMovie(id, language)
}
func (s *MovieService) GetPopular(page int, language, region string) (*models.TMDBResponse, error) {
return s.tmdb.GetPopularMovies(page, language, region)
}
func (s *MovieService) GetTopRated(page int, language, region string) (*models.TMDBResponse, error) {
return s.tmdb.GetTopRatedMovies(page, language, region)
}
func (s *MovieService) GetUpcoming(page int, language, region string) (*models.TMDBResponse, error) {
return s.tmdb.GetUpcomingMovies(page, language, region)
}
func (s *MovieService) GetNowPlaying(page int, language, region string) (*models.TMDBResponse, error) {
return s.tmdb.GetNowPlayingMovies(page, language, region)
}
func (s *MovieService) GetRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
return s.tmdb.GetMovieRecommendations(id, page, language)
}
func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBResponse, error) {
return s.tmdb.GetSimilarMovies(id, page, language)
}
func (s *MovieService) AddToFavorites(userID string, movieID string) error {
collection := s.db.Collection("users")
filter := bson.M{"_id": userID}
update := bson.M{
"$addToSet": bson.M{"favorites": movieID},
}
_, err := collection.UpdateOne(context.Background(), filter, update)
return err
}
func (s *MovieService) RemoveFromFavorites(userID string, movieID string) error {
collection := s.db.Collection("users")
filter := bson.M{"_id": userID}
update := bson.M{
"$pull": bson.M{"favorites": movieID},
}
_, err := collection.UpdateOne(context.Background(), filter, update)
return err
}
func (s *MovieService) GetFavorites(userID string, language string) ([]models.Movie, error) {
collection := s.db.Collection("users")
var user models.User
err := collection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&user)
if err != nil {
return nil, err
}
var movies []models.Movie
for _, movieIDStr := range user.Favorites {
movieID, err := strconv.Atoi(movieIDStr)
if err != nil {
continue
}
movie, err := s.tmdb.GetMovie(movieID, language)
if err != nil {
continue
}
movies = append(movies, *movie)
}
return movies, nil
}
func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
return s.tmdb.GetMovieExternalIDs(id)
}

212
pkg/services/reactions.go Normal file
View File

@@ -0,0 +1,212 @@
package services
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"neomovies-api/pkg/models"
)
type ReactionsService struct {
db *mongo.Database
client *http.Client
}
func NewReactionsService(db *mongo.Database) *ReactionsService {
return &ReactionsService{
db: db,
client: &http.Client{},
}
}
const CUB_API_URL = "https://cub.rip/api"
var VALID_REACTIONS = []string{"fire", "nice", "think", "bore", "shit"}
// Получить счетчики реакций для медиа из внешнего API (cub.rip)
func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) {
cubID := fmt.Sprintf("%s_%s", mediaType, mediaID)
resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", CUB_API_URL, cubID))
if err != nil {
return &models.ReactionCounts{}, nil // Возвращаем пустые счетчики при ошибке
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &models.ReactionCounts{}, nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return &models.ReactionCounts{}, nil
}
var response struct {
Result []struct {
Type string `json:"type"`
Counter int `json:"counter"`
} `json:"result"`
}
if err := json.Unmarshal(body, &response); err != nil {
return &models.ReactionCounts{}, nil
}
// Преобразуем в нашу структуру
counts := &models.ReactionCounts{}
for _, reaction := range response.Result {
switch reaction.Type {
case "fire":
counts.Fire = reaction.Counter
case "nice":
counts.Nice = reaction.Counter
case "think":
counts.Think = reaction.Counter
case "bore":
counts.Bore = reaction.Counter
case "shit":
counts.Shit = reaction.Counter
}
}
return counts, nil
}
// Получить реакцию пользователя для медиа
func (s *ReactionsService) GetUserReaction(userID, mediaType, mediaID string) (*models.Reaction, error) {
collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
var reaction models.Reaction
err := collection.FindOne(context.Background(), bson.M{
"userId": userID,
"mediaId": fullMediaID,
}).Decode(&reaction)
if err == mongo.ErrNoDocuments {
return nil, nil // Реакции нет
}
return &reaction, err
}
// Установить реакцию пользователя
func (s *ReactionsService) SetUserReaction(userID, mediaType, mediaID, reactionType string) error {
// Проверяем валидность типа реакции
if !s.isValidReactionType(reactionType) {
return fmt.Errorf("invalid reaction type: %s", reactionType)
}
collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
// Создаем или обновляем реакцию
filter := bson.M{
"userId": userID,
"mediaId": fullMediaID,
}
reaction := models.Reaction{
UserID: userID,
MediaID: fullMediaID,
Type: reactionType,
Created: time.Now().Format(time.RFC3339),
}
update := bson.M{
"$set": reaction,
}
upsert := true
_, err := collection.UpdateOne(context.Background(), filter, update, &options.UpdateOptions{
Upsert: &upsert,
})
if err != nil {
return err
}
// Отправляем реакцию в cub.rip API
go s.sendReactionToCub(fullMediaID, reactionType)
return nil
}
// Удалить реакцию пользователя
func (s *ReactionsService) RemoveUserReaction(userID, mediaType, mediaID string) error {
collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
_, err := collection.DeleteOne(context.Background(), bson.M{
"userId": userID,
"mediaId": fullMediaID,
})
return err
}
// Получить все реакции пользователя
func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models.Reaction, error) {
collection := s.db.Collection("reactions")
ctx := context.Background()
cursor, err := collection.Find(ctx, bson.M{"userId": userID})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var reactions []models.Reaction
if err := cursor.All(ctx, &reactions); err != nil {
return nil, err
}
return reactions, nil
}
func (s *ReactionsService) isValidReactionType(reactionType string) bool {
for _, valid := range VALID_REACTIONS {
if valid == reactionType {
return true
}
}
return false
}
// Отправка реакции в cub.rip API (асинхронно)
func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
// Формируем запрос к cub.rip API
url := fmt.Sprintf("%s/reactions/set", CUB_API_URL)
data := map[string]string{
"mediaId": mediaID,
"type": reactionType,
}
_, err := json.Marshal(data)
if err != nil {
return
}
// В данном случае мы отправляем простой POST запрос
// В будущем можно доработать для отправки JSON данных
resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType))
if err != nil {
return
}
defer resp.Body.Close()
// Логируем результат (в продакшене лучше использовать структурированное логирование)
if resp.StatusCode == http.StatusOK {
fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType)
}
}

479
pkg/services/tmdb.go Normal file
View File

@@ -0,0 +1,479 @@
package services
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"neomovies-api/pkg/models"
)
type TMDBService struct {
accessToken string
baseURL string
client *http.Client
}
func NewTMDBService(accessToken string) *TMDBService {
return &TMDBService{
accessToken: accessToken,
baseURL: "https://api.themoviedb.org/3",
client: &http.Client{},
}
}
func (s *TMDBService) makeRequest(endpoint string, target interface{}) error {
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return err
}
// Используем Bearer токен вместо API key в query параметрах
req.Header.Set("Authorization", "Bearer "+s.accessToken)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("TMDB API error: %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(target)
}
func (s *TMDBService) SearchMovies(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("query", query)
params.Set("page", strconv.Itoa(page))
params.Set("include_adult", "false")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
if year > 0 {
params.Set("year", strconv.Itoa(year))
}
endpoint := fmt.Sprintf("%s/search/movie?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) SearchMulti(query string, page int, language string) (*models.MultiSearchResponse, error) {
params := url.Values{}
params.Set("query", query)
params.Set("page", strconv.Itoa(page))
params.Set("include_adult", "false")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/search/multi?%s", s.baseURL, params.Encode())
var response models.MultiSearchResponse
err := s.makeRequest(endpoint, &response)
if err != nil {
return nil, err
}
// Фильтруем результаты: убираем "person", и без названия
filteredResults := make([]models.MultiSearchResult, 0)
for _, result := range response.Results {
if result.MediaType == "person" {
continue
}
hasTitle := false
if result.MediaType == "movie" && result.Title != "" {
hasTitle = true
} else if result.MediaType == "tv" && result.Name != "" {
hasTitle = true
}
if hasTitle {
filteredResults = append(filteredResults, result)
}
}
response.Results = filteredResults
response.TotalResults = len(filteredResults)
return &response, nil
}
func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("query", query)
params.Set("page", strconv.Itoa(page))
params.Set("include_adult", "false")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if firstAirDateYear > 0 {
params.Set("first_air_date_year", strconv.Itoa(firstAirDateYear))
}
endpoint := fmt.Sprintf("%s/search/tv?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) {
params := url.Values{}
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode())
var movie models.Movie
err := s.makeRequest(endpoint, &movie)
return &movie, err
}
func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) {
params := url.Values{}
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode())
var tvShow models.TVShow
err := s.makeRequest(endpoint, &tvShow)
return &tvShow, err
}
func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) {
params := url.Values{}
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/genre/%s/list?%s", s.baseURL, mediaType, params.Encode())
var response models.GenresResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) {
// Получаем жанры фильмов
movieGenres, err := s.GetGenres("movie", "ru-RU")
if err != nil {
return nil, err
}
// Получаем жанры сериалов
tvGenres, err := s.GetGenres("tv", "ru-RU")
if err != nil {
return nil, err
}
// Объединяем жанры, убирая дубликаты
allGenres := make(map[int]models.Genre)
for _, genre := range movieGenres.Genres {
allGenres[genre.ID] = genre
}
for _, genre := range tvGenres.Genres {
allGenres[genre.ID] = genre
}
// Преобразуем обратно в слайс
var genres []models.Genre
for _, genre := range allGenres {
genres = append(genres, genre)
}
return &models.GenresResponse{Genres: genres}, nil
}
func (s *TMDBService) GetPopularMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/popular?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/top_rated?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/upcoming?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/now_playing?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/movie/%d/recommendations?%s", s.baseURL, id, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/movie/%d/similar?%s", s.baseURL, id, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/on_the_air?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/%d/recommendations?%s", s.baseURL, id, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/%d/similar?%s", s.baseURL, id, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) {
endpoint := fmt.Sprintf("%s/movie/%d/external_ids", s.baseURL, id)
var ids models.ExternalIDs
err := s.makeRequest(endpoint, &ids)
return &ids, err
}
func (s *TMDBService) GetTVExternalIDs(id int) (*models.ExternalIDs, error) {
endpoint := fmt.Sprintf("%s/tv/%d/external_ids", s.baseURL, id)
var ids models.ExternalIDs
err := s.makeRequest(endpoint, &ids)
return &ids, err
}
func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
params.Set("with_genres", strconv.Itoa(genreID))
params.Set("sort_by", "popularity.desc")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/discover/movie?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}

832
pkg/services/torrent.go Normal file
View File

@@ -0,0 +1,832 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"neomovies-api/pkg/models"
)
type TorrentService struct {
client *http.Client
baseURL string
apiKey string
}
func NewTorrentService() *TorrentService {
return &TorrentService{
client: &http.Client{Timeout: 8 * time.Second},
baseURL: "http://redapi.cfhttp.top",
apiKey: "", // Может быть установлен через переменные окружения
}
}
// SearchTorrents - основной метод поиска торрентов через RedAPI
func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) {
searchParams := url.Values{}
// Добавляем все параметры поиска
for key, value := range params {
if value != "" {
if key == "category" {
searchParams.Add("category[]", value)
} else {
searchParams.Add(key, value)
}
}
}
if s.apiKey != "" {
searchParams.Add("apikey", s.apiKey)
}
searchURL := fmt.Sprintf("%s/api/v2.0/indexers/all/results?%s", s.baseURL, searchParams.Encode())
resp, err := s.client.Get(searchURL)
if err != nil {
return nil, fmt.Errorf("failed to search torrents: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var redAPIResponse models.RedAPIResponse
if err := json.Unmarshal(body, &redAPIResponse); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
results := s.parseRedAPIResults(redAPIResponse)
return &models.TorrentSearchResponse{
Query: params["query"],
Results: results,
Total: len(results),
}, nil
}
// parseRedAPIResults преобразует результаты RedAPI в наш формат
func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models.TorrentResult {
var results []models.TorrentResult
for _, torrent := range data.Results {
// Обрабатываем размер - может быть строкой или числом
var sizeStr string
switch v := torrent.Size.(type) {
case string:
sizeStr = v
case float64:
sizeStr = fmt.Sprintf("%.0f", v)
case int:
sizeStr = fmt.Sprintf("%d", v)
default:
sizeStr = ""
}
result := models.TorrentResult{
Title: torrent.Title,
Tracker: torrent.Tracker,
Size: sizeStr,
Seeders: torrent.Seeders,
Peers: torrent.Peers,
MagnetLink: torrent.MagnetUri,
PublishDate: torrent.PublishDate,
Category: torrent.CategoryDesc,
Details: torrent.Details,
Source: "RedAPI",
}
// Добавляем информацию из Info если она есть
if torrent.Info != nil {
// Обрабатываем качество - может быть строкой или числом
switch v := torrent.Info.Quality.(type) {
case string:
result.Quality = v
case float64:
result.Quality = fmt.Sprintf("%.0fp", v)
case int:
result.Quality = fmt.Sprintf("%dp", v)
}
result.Voice = torrent.Info.Voices
result.Types = torrent.Info.Types
result.Seasons = torrent.Info.Seasons
}
// Если качество не определено через Info, пытаемся извлечь из названия
if result.Quality == "" {
result.Quality = s.ExtractQuality(result.Title)
}
results = append(results, result)
}
return results
}
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) {
// Получаем информацию о фильме/сериале из TMDB
title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType)
if err != nil {
return nil, fmt.Errorf("failed to get title from TMDB: %w", err)
}
// Формируем параметры поиска
params := make(map[string]string)
params["imdb"] = imdbID
params["title"] = title
params["title_original"] = originalTitle
params["year"] = year
// Устанавливаем тип контента и категорию
switch mediaType {
case "movie":
params["is_serial"] = "1"
params["category"] = "2000"
case "tv", "series":
params["is_serial"] = "2"
params["category"] = "5000"
case "anime":
params["is_serial"] = "5"
params["category"] = "5070"
default:
params["is_serial"] = "1"
params["category"] = "2000"
}
// Добавляем сезон если указан
if options != nil && options.Season != nil {
params["season"] = strconv.Itoa(*options.Season)
}
// Выполняем поиск
response, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
// Применяем фильтрацию
if options != nil {
response.Results = s.FilterByContentType(response.Results, options.ContentType)
response.Results = s.FilterTorrents(response.Results, options)
response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder)
response.Total = len(response.Results)
}
return response, nil
}
// SearchMovies - поиск фильмов с дополнительной фильтрацией
func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
params := map[string]string{
"title": title,
"title_original": originalTitle,
"year": year,
"is_serial": "1",
"category": "2000",
}
response, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
response.Results = s.FilterByContentType(response.Results, "movie")
response.Total = len(response.Results)
return response, nil
}
// SearchSeries - поиск сериалов с поддержкой fallback и фильтрации по сезону
func (s *TorrentService) SearchSeries(title, originalTitle, year string, season *int) (*models.TorrentSearchResponse, error) {
params := map[string]string{
"title": title,
"title_original": originalTitle,
"year": year,
"is_serial": "2",
"category": "5000",
}
if season != nil {
params["season"] = strconv.Itoa(*season)
}
response, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
// Если указан сезон и результатов мало, делаем fallback-поиск без сезона и фильтруем на клиенте
if season != nil && len(response.Results) < 5 {
paramsNoSeason := map[string]string{
"title": title,
"title_original": originalTitle,
"year": year,
"is_serial": "2",
"category": "5000",
}
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
if err == nil {
filtered := s.filterBySeason(fallbackResp.Results, *season)
// Объединяем и убираем дубликаты по MagnetLink
all := append(response.Results, filtered...)
unique := make([]models.TorrentResult, 0, len(all))
seen := make(map[string]bool)
for _, t := range all {
if !seen[t.MagnetLink] {
unique = append(unique, t)
seen[t.MagnetLink] = true
}
}
response.Results = unique
}
}
response.Results = s.FilterByContentType(response.Results, "serial")
response.Total = len(response.Results)
return response, nil
}
// filterBySeason - фильтрация результатов по сезону (аналогично JS)
func (s *TorrentService) filterBySeason(results []models.TorrentResult, season int) []models.TorrentResult {
if season == 0 {
return results
}
filtered := make([]models.TorrentResult, 0, len(results))
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
for _, torrent := range results {
found := false
// Проверяем поле seasons
for _, s := range torrent.Seasons {
if s == season {
found = true
break
}
}
if found {
filtered = append(filtered, torrent)
continue
}
// Проверяем в названии
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
for _, match := range matches {
seasonNumber := 0
if match[1] != "" {
seasonNumber, _ = strconv.Atoi(match[1])
} else if match[2] != "" {
seasonNumber, _ = strconv.Atoi(match[2])
}
if seasonNumber == season {
filtered = append(filtered, torrent)
break
}
}
}
return filtered
}
// SearchAnime - поиск аниме
func (s *TorrentService) SearchAnime(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
params := map[string]string{
"title": title,
"title_original": originalTitle,
"year": year,
"is_serial": "5",
"category": "5070",
}
response, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
response.Results = s.FilterByContentType(response.Results, "anime")
response.Total = len(response.Results)
return response, nil
}
// AllohaResponse - структура ответа от Alloha API
type AllohaResponse struct {
Status string `json:"status"`
Data struct {
Name string `json:"name"`
OriginalName string `json:"original_name"`
Year int `json:"year"`
Category int `json:"category"` // 1-фильм, 2-сериал
} `json:"data"`
}
// getMovieInfoByIMDB - получение информации через Alloha API (как в JavaScript версии)
func (s *TorrentService) getMovieInfoByIMDB(imdbID string) (string, string, string, error) {
// Используем тот же токен что и в JavaScript версии
endpoint := fmt.Sprintf("https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&imdb=%s", imdbID)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return "", "", "", err
}
resp, err := s.client.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", err
}
var allohaResponse AllohaResponse
if err := json.Unmarshal(body, &allohaResponse); err != nil {
return "", "", "", err
}
if allohaResponse.Status != "success" {
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
}
title := allohaResponse.Data.Name
originalTitle := allohaResponse.Data.OriginalName
year := ""
if allohaResponse.Data.Year > 0 {
year = strconv.Itoa(allohaResponse.Data.Year)
}
return title, originalTitle, year, nil
}
// getTitleFromTMDB - получение информации из TMDB (с fallback на Alloha API)
func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, mediaType string) (string, string, string, error) {
// Сначала пробуем Alloha API (как в JavaScript версии)
title, originalTitle, year, err := s.getMovieInfoByIMDB(imdbID)
if err == nil {
return title, originalTitle, year, nil
}
// Если Alloha API не работает, пробуем TMDB API
endpoint := fmt.Sprintf("https://api.themoviedb.org/3/find/%s", imdbID)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return "", "", "", err
}
params := url.Values{}
params.Set("external_source", "imdb_id")
params.Set("language", "ru-RU")
req.URL.RawQuery = params.Encode()
req.Header.Set("Authorization", "Bearer "+tmdbService.accessToken)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", err
}
var findResponse struct {
MovieResults []struct {
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
ReleaseDate string `json:"release_date"`
} `json:"movie_results"`
TVResults []struct {
Name string `json:"name"`
OriginalName string `json:"original_name"`
FirstAirDate string `json:"first_air_date"`
} `json:"tv_results"`
}
if err := json.Unmarshal(body, &findResponse); err != nil {
return "", "", "", err
}
if mediaType == "movie" && len(findResponse.MovieResults) > 0 {
movie := findResponse.MovieResults[0]
title := movie.Title
originalTitle := movie.OriginalTitle
year := ""
if movie.ReleaseDate != "" {
year = movie.ReleaseDate[:4]
}
return title, originalTitle, year, nil
}
if (mediaType == "tv" || mediaType == "series") && len(findResponse.TVResults) > 0 {
tv := findResponse.TVResults[0]
title := tv.Name
originalTitle := tv.OriginalName
year := ""
if tv.FirstAirDate != "" {
year = tv.FirstAirDate[:4]
}
return title, originalTitle, year, nil
}
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
}
// FilterByContentType - фильтрация по типу контента
func (s *TorrentService) FilterByContentType(results []models.TorrentResult, contentType string) []models.TorrentResult {
if contentType == "" {
return results
}
var filtered []models.TorrentResult
for _, torrent := range results {
// Фильтрация по полю types, если оно есть
if len(torrent.Types) > 0 {
switch contentType {
case "movie":
if s.containsAny(torrent.Types, []string{"movie", "multfilm", "documovie"}) {
filtered = append(filtered, torrent)
}
case "serial":
if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) {
filtered = append(filtered, torrent)
}
case "anime":
if s.contains(torrent.Types, "anime") {
filtered = append(filtered, torrent)
}
}
continue
}
// Фильтрация по названию, если types недоступно
title := strings.ToLower(torrent.Title)
switch contentType {
case "movie":
if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
filtered = append(filtered, torrent)
}
case "serial":
if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
filtered = append(filtered, torrent)
}
case "anime":
if torrent.Category == "TV/Anime" || regexp.MustCompile(`(?i)anime`).MatchString(title) {
filtered = append(filtered, torrent)
}
default:
filtered = append(filtered, torrent)
}
}
return filtered
}
// FilterTorrents - фильтрация торрентов по опциям
func (s *TorrentService) FilterTorrents(torrents []models.TorrentResult, options *models.TorrentSearchOptions) []models.TorrentResult {
if options == nil {
return torrents
}
var filtered []models.TorrentResult
for _, torrent := range torrents {
// Фильтрация по качеству
if len(options.Quality) > 0 {
found := false
for _, quality := range options.Quality {
if strings.EqualFold(torrent.Quality, quality) {
found = true
break
}
}
if !found {
continue
}
}
// Фильтрация по минимальному качеству
if options.MinQuality != "" && !s.qualityMeetsMinimum(torrent.Quality, options.MinQuality) {
continue
}
// Фильтрация по максимальному качеству
if options.MaxQuality != "" && !s.qualityMeetsMaximum(torrent.Quality, options.MaxQuality) {
continue
}
// Исключение качеств
if len(options.ExcludeQualities) > 0 {
excluded := false
for _, excludeQuality := range options.ExcludeQualities {
if strings.EqualFold(torrent.Quality, excludeQuality) {
excluded = true
break
}
}
if excluded {
continue
}
}
// Фильтрация по HDR
if options.HDR != nil {
hasHDR := regexp.MustCompile(`(?i)(hdr|dolby.vision|dv)`).MatchString(torrent.Title)
if *options.HDR != hasHDR {
continue
}
}
// Фильтрация по HEVC
if options.HEVC != nil {
hasHEVC := regexp.MustCompile(`(?i)(hevc|h\.265|x265)`).MatchString(torrent.Title)
if *options.HEVC != hasHEVC {
continue
}
}
// Фильтрация по сезону (дополнительная на клиенте)
if options.Season != nil {
if !s.matchesSeason(torrent, *options.Season) {
continue
}
}
filtered = append(filtered, torrent)
}
return filtered
}
// matchesSeason - проверка соответствия сезону
func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int) bool {
// Проверяем в поле seasons
for _, s := range torrent.Seasons {
if s == season {
return true
}
}
// Проверяем в названии
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
for _, match := range matches {
seasonNumber := 0
if match[1] != "" {
seasonNumber, _ = strconv.Atoi(match[1])
} else if match[2] != "" {
seasonNumber, _ = strconv.Atoi(match[2])
}
if seasonNumber == season {
return true
}
}
return false
}
// ExtractQuality - извлечение качества из названия
func (s *TorrentService) ExtractQuality(title string) string {
title = strings.ToUpper(title)
qualityPatterns := []struct {
pattern string
quality string
}{
{`2160P|4K`, "2160p"},
{`1440P`, "1440p"},
{`1080P`, "1080p"},
{`720P`, "720p"},
{`480P`, "480p"},
{`360P`, "360p"},
}
for _, qp := range qualityPatterns {
if matched, _ := regexp.MatchString(qp.pattern, title); matched {
if qp.quality == "2160p" {
return "4K"
}
return qp.quality
}
}
return "Unknown"
}
// sortTorrents - сортировка результатов
func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, sortOrder string) []models.TorrentResult {
if sortBy == "" {
sortBy = "seeders"
}
if sortOrder == "" {
sortOrder = "desc"
}
sort.Slice(torrents, func(i, j int) bool {
var less bool
switch sortBy {
case "seeders":
less = torrents[i].Seeders < torrents[j].Seeders
case "size":
less = s.compareSizes(torrents[i].Size, torrents[j].Size)
case "date":
less = torrents[i].PublishDate < torrents[j].PublishDate
default:
less = torrents[i].Seeders < torrents[j].Seeders
}
if sortOrder == "asc" {
return less
}
return !less
})
return torrents
}
// GroupByQuality - группировка по качеству
func (s *TorrentService) GroupByQuality(results []models.TorrentResult) map[string][]models.TorrentResult {
groups := make(map[string][]models.TorrentResult)
for _, torrent := range results {
quality := torrent.Quality
if quality == "" {
quality = "unknown"
}
// Объединяем 4K и 2160p в одну группу
if quality == "2160p" {
quality = "4K"
}
groups[quality] = append(groups[quality], torrent)
}
// Сортируем торренты внутри каждой группы по сидам
for quality := range groups {
sort.Slice(groups[quality], func(i, j int) bool {
return groups[quality][i].Seeders > groups[quality][j].Seeders
})
}
return groups
}
// GroupBySeason - группировка по сезонам
func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[string][]models.TorrentResult {
groups := make(map[string][]models.TorrentResult)
for _, torrent := range results {
seasons := make(map[int]bool)
// Извлекаем сезоны из поля seasons
for _, season := range torrent.Seasons {
seasons[season] = true
}
// Извлекаем сезоны из названия
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
for _, match := range matches {
seasonNumber := 0
if match[1] != "" {
seasonNumber, _ = strconv.Atoi(match[1])
} else if match[2] != "" {
seasonNumber, _ = strconv.Atoi(match[2])
}
if seasonNumber > 0 {
seasons[seasonNumber] = true
}
}
// Если сезоны не найдены, добавляем в группу "unknown"
if len(seasons) == 0 {
groups["Неизвестно"] = append(groups["Неизвестно"], torrent)
} else {
// Добавляем торрент во все соответствующие группы сезонов
for season := range seasons {
seasonKey := fmt.Sprintf("Сезон %d", season)
// Проверяем дубликаты
found := false
for _, existing := range groups[seasonKey] {
if existing.MagnetLink == torrent.MagnetLink {
found = true
break
}
}
if !found {
groups[seasonKey] = append(groups[seasonKey], torrent)
}
}
}
}
// Сортируем торренты внутри каждой группы по сидам
for season := range groups {
sort.Slice(groups[season], func(i, j int) bool {
return groups[season][i].Seeders > groups[season][j].Seeders
})
}
return groups
}
// GetAvailableSeasons - получение доступных сезонов для сериала
func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string) ([]int, error) {
response, err := s.SearchSeries(title, originalTitle, year, nil)
if err != nil {
return nil, err
}
seasonsSet := make(map[int]bool)
for _, torrent := range response.Results {
// Извлекаем из поля seasons
for _, season := range torrent.Seasons {
seasonsSet[season] = true
}
// Извлекаем из названия
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
for _, match := range matches {
seasonNumber := 0
if match[1] != "" {
seasonNumber, _ = strconv.Atoi(match[1])
} else if match[2] != "" {
seasonNumber, _ = strconv.Atoi(match[2])
}
if seasonNumber > 0 {
seasonsSet[seasonNumber] = true
}
}
}
var seasons []int
for season := range seasonsSet {
seasons = append(seasons, season)
}
sort.Ints(seasons)
return seasons, nil
}
// Вспомогательные функции
func (s *TorrentService) qualityMeetsMinimum(quality, minQuality string) bool {
qualityOrder := map[string]int{
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
}
currentLevel := qualityOrder[strings.ToLower(quality)]
minLevel := qualityOrder[strings.ToLower(minQuality)]
return currentLevel >= minLevel
}
func (s *TorrentService) qualityMeetsMaximum(quality, maxQuality string) bool {
qualityOrder := map[string]int{
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
}
currentLevel := qualityOrder[strings.ToLower(quality)]
maxLevel := qualityOrder[strings.ToLower(maxQuality)]
return currentLevel <= maxLevel
}
func (s *TorrentService) compareSizes(size1, size2 string) bool {
// Простое сравнение размеров (можно улучшить)
return len(size1) < len(size2)
}
func (s *TorrentService) contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
func (s *TorrentService) containsAny(slice []string, items []string) bool {
for _, item := range items {
if s.contains(slice, item) {
return true
}
}
return false
}

55
pkg/services/tv.go Normal file
View File

@@ -0,0 +1,55 @@
package services
import (
"go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models"
)
type TVService struct {
db *mongo.Database
tmdb *TMDBService
}
func NewTVService(db *mongo.Database, tmdb *TMDBService) *TVService {
return &TVService{
db: db,
tmdb: tmdb,
}
}
func (s *TVService) Search(query string, page int, language string, year int) (*models.TMDBTVResponse, error) {
return s.tmdb.SearchTVShows(query, page, language, year)
}
func (s *TVService) GetByID(id int, language string) (*models.TVShow, error) {
return s.tmdb.GetTVShow(id, language)
}
func (s *TVService) GetPopular(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetPopularTVShows(page, language)
}
func (s *TVService) GetTopRated(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetTopRatedTVShows(page, language)
}
func (s *TVService) GetOnTheAir(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetOnTheAirTVShows(page, language)
}
func (s *TVService) GetAiringToday(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetAiringTodayTVShows(page, language)
}
func (s *TVService) GetRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetTVRecommendations(id, page, language)
}
func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetSimilarTVShows(id, page, language)
}
func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
return s.tmdb.GetTVExternalIDs(id)
}