This commit is contained in:
2025-11-19 15:02:59 +00:00
parent 5ec97187e4
commit 20d0f5e43e
39 changed files with 10143 additions and 10094 deletions

View File

@@ -1,309 +1,309 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"time"
"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
}
// Получаем информацию о клиенте для refresh токена
userAgent := r.Header.Get("User-Agent")
ipAddress := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ipAddress = forwarded
}
response, err := h.authService.LoginWithTokens(req, userAgent, ipAddress)
if err != nil {
statusCode := http.StatusBadRequest
if err.Error() == "Account not activated. Please verify your email." {
statusCode = http.StatusForbidden
}
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) GoogleLogin(w http.ResponseWriter, r *http.Request) {
state := generateState()
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Path: "/", Expires: time.Now().Add(10 * time.Minute)})
url, err := h.authService.GetGoogleLoginURL(state)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
func (h *AuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
state := q.Get("state")
code := q.Get("code")
preferJSON := q.Get("response") == "json" || strings.Contains(r.Header.Get("Accept"), "application/json")
cookie, _ := r.Cookie("oauth_state")
if cookie == nil || cookie.Value != state || code == "" {
if preferJSON {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: "invalid oauth state"})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect("", "invalid_state")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
http.Error(w, "invalid oauth state", http.StatusBadRequest)
return
}
resp, err := h.authService.HandleGoogleCallback(r.Context(), code)
if err != nil {
if preferJSON {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: err.Error()})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect("", "auth_failed")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if preferJSON {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect(resp.Token, "")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, 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"})
}
func (h *AuthHandler) DeleteAccount(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
}
if err := h.authService.DeleteAccount(r.Context(), userID); 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: "Account deleted successfully"})
}
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)
}
// RefreshToken refreshes an access token using a refresh token
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
var req models.RefreshTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Получаем информацию о клиенте
userAgent := r.Header.Get("User-Agent")
ipAddress := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ipAddress = forwarded
}
tokenPair, err := h.authService.RefreshAccessToken(req.RefreshToken, userAgent, ipAddress)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tokenPair,
Message: "Token refreshed successfully",
})
}
// RevokeRefreshToken revokes a specific refresh token
func (h *AuthHandler) RevokeRefreshToken(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 req models.RefreshTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
err := h.authService.RevokeRefreshToken(userID, req.RefreshToken)
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: "Refresh token revoked successfully",
})
}
// RevokeAllRefreshTokens revokes all refresh tokens for the current user
func (h *AuthHandler) RevokeAllRefreshTokens(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
}
err := h.authService.RevokeAllRefreshTokens(userID)
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: "All refresh tokens revoked successfully",
})
}
// helpers
func generateState() string { return uuidNew() }
package handlers
import (
"encoding/json"
"net/http"
"strings"
"time"
"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
}
// Получаем информацию о клиенте для refresh токена
userAgent := r.Header.Get("User-Agent")
ipAddress := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ipAddress = forwarded
}
response, err := h.authService.LoginWithTokens(req, userAgent, ipAddress)
if err != nil {
statusCode := http.StatusBadRequest
if err.Error() == "Account not activated. Please verify your email." {
statusCode = http.StatusForbidden
}
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) GoogleLogin(w http.ResponseWriter, r *http.Request) {
state := generateState()
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Path: "/", Expires: time.Now().Add(10 * time.Minute)})
url, err := h.authService.GetGoogleLoginURL(state)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
func (h *AuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
state := q.Get("state")
code := q.Get("code")
preferJSON := q.Get("response") == "json" || strings.Contains(r.Header.Get("Accept"), "application/json")
cookie, _ := r.Cookie("oauth_state")
if cookie == nil || cookie.Value != state || code == "" {
if preferJSON {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: "invalid oauth state"})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect("", "invalid_state")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
http.Error(w, "invalid oauth state", http.StatusBadRequest)
return
}
resp, err := h.authService.HandleGoogleCallback(r.Context(), code)
if err != nil {
if preferJSON {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: err.Error()})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect("", "auth_failed")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if preferJSON {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"})
return
}
redirectURL, ok := h.authService.BuildFrontendRedirect(resp.Token, "")
if ok {
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, 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"})
}
func (h *AuthHandler) DeleteAccount(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
}
if err := h.authService.DeleteAccount(r.Context(), userID); 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: "Account deleted successfully"})
}
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)
}
// RefreshToken refreshes an access token using a refresh token
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
var req models.RefreshTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Получаем информацию о клиенте
userAgent := r.Header.Get("User-Agent")
ipAddress := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ipAddress = forwarded
}
tokenPair, err := h.authService.RefreshAccessToken(req.RefreshToken, userAgent, ipAddress)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: tokenPair,
Message: "Token refreshed successfully",
})
}
// RevokeRefreshToken revokes a specific refresh token
func (h *AuthHandler) RevokeRefreshToken(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 req models.RefreshTokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
err := h.authService.RevokeRefreshToken(userID, req.RefreshToken)
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: "Refresh token revoked successfully",
})
}
// RevokeAllRefreshTokens revokes all refresh tokens for the current user
func (h *AuthHandler) RevokeAllRefreshTokens(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
}
err := h.authService.RevokeAllRefreshTokens(userID)
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: "All refresh tokens revoked successfully",
})
}
// helpers
func generateState() string { return uuidNew() }

View File

@@ -1,7 +1,7 @@
package handlers
import (
"github.com/google/uuid"
)
func uuidNew() string { return uuid.New().String() }
package handlers
import (
"github.com/google/uuid"
)
func uuidNew() string { return uuid.New().String() }

View File

@@ -1,139 +1,139 @@
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
kpService *services.KinopoiskService
}
func NewCategoriesHandler(tmdbService *services.TMDBService) *CategoriesHandler {
// Для совместимости, kpService может быть добавлен позже через setter при инициализации в main.go/api/index.go
return &CategoriesHandler{tmdbService: tmdbService}
}
func (h *CategoriesHandler) WithKinopoisk(kp *services.KinopoiskService) *CategoriesHandler {
h.kpService = kp
return h
}
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
}
// Преобразуем жанры в категории (пока TMDB). Для KP — можно замаппить фиксированный список
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) GetMediaByCategory(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"
}
mediaType := r.URL.Query().Get("type")
if mediaType == "" {
mediaType = "movie" // По умолчанию фильмы для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
source := r.URL.Query().Get("source") // "kp" | "tmdb"
var data interface{}
var err2 error
if source == "kp" && h.kpService != nil {
// KP не имеет прямого discover по genre id TMDB — здесь можно реализовать маппинг slug->поисковый запрос
// Для простоты: используем keyword поиск по имени категории (slug как ключевое слово)
// Получим человекочитаемое имя жанра из TMDB как приближение
if mediaType == "movie" {
// Поиском KP (keyword) эмулируем категорию
data, err2 = h.kpService.SearchFilms(r.URL.Query().Get("name"), page)
} else {
// Для сериалов у KP: используем тот же поиск (KP выдаёт и сериалы в некоторых случаях)
data, err2 = h.kpService.SearchFilms(r.URL.Query().Get("name"), page)
}
} else {
if mediaType == "movie" {
data, err2 = h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
} else {
data, err2 = h.tmdbService.DiscoverTVByGenre(categoryID, page, language)
}
}
if err2 != nil {
http.Error(w, err2.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: data,
Message: "Media retrieved successfully",
})
}
// Старый метод для обратной совместимости
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
// Просто перенаправляем на новый метод
h.GetMediaByCategory(w, r)
}
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
}
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
kpService *services.KinopoiskService
}
func NewCategoriesHandler(tmdbService *services.TMDBService) *CategoriesHandler {
// Для совместимости, kpService может быть добавлен позже через setter при инициализации в main.go/api/index.go
return &CategoriesHandler{tmdbService: tmdbService}
}
func (h *CategoriesHandler) WithKinopoisk(kp *services.KinopoiskService) *CategoriesHandler {
h.kpService = kp
return h
}
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
}
// Преобразуем жанры в категории (пока TMDB). Для KP — можно замаппить фиксированный список
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) GetMediaByCategory(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"
}
mediaType := r.URL.Query().Get("type")
if mediaType == "" {
mediaType = "movie" // По умолчанию фильмы для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
source := r.URL.Query().Get("source") // "kp" | "tmdb"
var data interface{}
var err2 error
if source == "kp" && h.kpService != nil {
// KP не имеет прямого discover по genre id TMDB — здесь можно реализовать маппинг slug->поисковый запрос
// Для простоты: используем keyword поиск по имени категории (slug как ключевое слово)
// Получим человекочитаемое имя жанра из TMDB как приближение
if mediaType == "movie" {
// Поиском KP (keyword) эмулируем категорию
data, err2 = h.kpService.SearchFilms(r.URL.Query().Get("name"), page)
} else {
// Для сериалов у KP: используем тот же поиск (KP выдаёт и сериалы в некоторых случаях)
data, err2 = h.kpService.SearchFilms(r.URL.Query().Get("name"), page)
}
} else {
if mediaType == "movie" {
data, err2 = h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
} else {
data, err2 = h.tmdbService.DiscoverTVByGenre(categoryID, page, language)
}
}
if err2 != nil {
http.Error(w, err2.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: data,
Message: "Media retrieved successfully",
})
}
// Старый метод для обратной совместимости
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
// Просто перенаправляем на новый метод
h.GetMediaByCategory(w, r)
}
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,262 +1,262 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/gorilla/mux"
"neomovies-api/pkg/config"
"neomovies-api/pkg/middleware"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type FavoritesHandler struct {
favoritesService *services.FavoritesService
config *config.Config
}
func NewFavoritesHandler(favoritesService *services.FavoritesService, cfg *config.Config) *FavoritesHandler {
return &FavoritesHandler{
favoritesService: favoritesService,
config: cfg,
}
}
func (h *FavoritesHandler) 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.StatusUnauthorized)
return
}
favorites, err := h.favoritesService.GetFavorites(userID)
if err != nil {
http.Error(w, "Failed to get favorites: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: favorites,
Message: "Favorites retrieved successfully",
})
}
func (h *FavoritesHandler) 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.StatusUnauthorized)
return
}
vars := mux.Vars(r)
mediaID := vars["id"]
mediaType := r.URL.Query().Get("type")
if mediaID == "" {
http.Error(w, "Media ID is required", http.StatusBadRequest)
return
}
if mediaType == "" {
mediaType = "movie" // По умолчанию фильм для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
// Получаем информацию о медиа на русском языке
mediaInfo, err := h.fetchMediaInfoRussian(mediaID, mediaType)
if err != nil {
http.Error(w, "Failed to fetch media information: "+err.Error(), http.StatusInternalServerError)
return
}
err = h.favoritesService.AddToFavoritesWithInfo(userID, mediaID, mediaType, mediaInfo)
if err != nil {
http.Error(w, "Failed to add to favorites: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Added to favorites successfully",
})
}
func (h *FavoritesHandler) 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.StatusUnauthorized)
return
}
vars := mux.Vars(r)
mediaID := vars["id"]
mediaType := r.URL.Query().Get("type")
if mediaID == "" {
http.Error(w, "Media ID is required", http.StatusBadRequest)
return
}
if mediaType == "" {
mediaType = "movie" // По умолчанию фильм для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
err := h.favoritesService.RemoveFromFavorites(userID, mediaID, mediaType)
if err != nil {
http.Error(w, "Failed to remove from favorites: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Removed from favorites successfully",
})
}
func (h *FavoritesHandler) CheckIsFavorite(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
mediaID := vars["id"]
mediaType := r.URL.Query().Get("type")
if mediaID == "" {
http.Error(w, "Media ID is required", http.StatusBadRequest)
return
}
if mediaType == "" {
mediaType = "movie" // По умолчанию фильм для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
isFavorite, err := h.favoritesService.IsFavorite(userID, mediaID, mediaType)
if err != nil {
http.Error(w, "Failed to check favorite status: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: map[string]bool{"isFavorite": isFavorite},
})
}
// fetchMediaInfoRussian получает информацию о медиа на русском языке из TMDB
func (h *FavoritesHandler) fetchMediaInfoRussian(mediaID, mediaType string) (*models.MediaInfo, error) {
var url string
if mediaType == "movie" {
url = fmt.Sprintf("https://api.themoviedb.org/3/movie/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken)
} else {
url = fmt.Sprintf("https://api.themoviedb.org/3/tv/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken)
}
client := &http.Client{Timeout: 6 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch from TMDB: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TMDB API error: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var tmdbResponse map[string]interface{}
if err := json.Unmarshal(body, &tmdbResponse); err != nil {
return nil, fmt.Errorf("failed to parse TMDB response: %w", err)
}
mediaInfo := &models.MediaInfo{
ID: mediaID,
MediaType: mediaType,
}
// Заполняем информацию в зависимости от типа медиа
if mediaType == "movie" {
if title, ok := tmdbResponse["title"].(string); ok {
mediaInfo.Title = title
}
if originalTitle, ok := tmdbResponse["original_title"].(string); ok {
mediaInfo.OriginalTitle = originalTitle
}
if releaseDate, ok := tmdbResponse["release_date"].(string); ok {
mediaInfo.ReleaseDate = releaseDate
}
} else {
if name, ok := tmdbResponse["name"].(string); ok {
mediaInfo.Title = name
}
if originalName, ok := tmdbResponse["original_name"].(string); ok {
mediaInfo.OriginalTitle = originalName
}
if firstAirDate, ok := tmdbResponse["first_air_date"].(string); ok {
mediaInfo.FirstAirDate = firstAirDate
}
}
// Общие поля
if overview, ok := tmdbResponse["overview"].(string); ok {
mediaInfo.Overview = overview
}
if posterPath, ok := tmdbResponse["poster_path"].(string); ok {
mediaInfo.PosterPath = posterPath
}
if backdropPath, ok := tmdbResponse["backdrop_path"].(string); ok {
mediaInfo.BackdropPath = backdropPath
}
if voteAverage, ok := tmdbResponse["vote_average"].(float64); ok {
mediaInfo.VoteAverage = voteAverage
}
if voteCount, ok := tmdbResponse["vote_count"].(float64); ok {
mediaInfo.VoteCount = int(voteCount)
}
if popularity, ok := tmdbResponse["popularity"].(float64); ok {
mediaInfo.Popularity = popularity
}
// Жанры
if genres, ok := tmdbResponse["genres"].([]interface{}); ok {
for _, genre := range genres {
if genreMap, ok := genre.(map[string]interface{}); ok {
if genreID, ok := genreMap["id"].(float64); ok {
mediaInfo.GenreIDs = append(mediaInfo.GenreIDs, int(genreID))
}
}
}
}
return mediaInfo, nil
}
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/gorilla/mux"
"neomovies-api/pkg/config"
"neomovies-api/pkg/middleware"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type FavoritesHandler struct {
favoritesService *services.FavoritesService
config *config.Config
}
func NewFavoritesHandler(favoritesService *services.FavoritesService, cfg *config.Config) *FavoritesHandler {
return &FavoritesHandler{
favoritesService: favoritesService,
config: cfg,
}
}
func (h *FavoritesHandler) 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.StatusUnauthorized)
return
}
favorites, err := h.favoritesService.GetFavorites(userID)
if err != nil {
http.Error(w, "Failed to get favorites: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: favorites,
Message: "Favorites retrieved successfully",
})
}
func (h *FavoritesHandler) 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.StatusUnauthorized)
return
}
vars := mux.Vars(r)
mediaID := vars["id"]
mediaType := r.URL.Query().Get("type")
if mediaID == "" {
http.Error(w, "Media ID is required", http.StatusBadRequest)
return
}
if mediaType == "" {
mediaType = "movie" // По умолчанию фильм для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
// Получаем информацию о медиа на русском языке
mediaInfo, err := h.fetchMediaInfoRussian(mediaID, mediaType)
if err != nil {
http.Error(w, "Failed to fetch media information: "+err.Error(), http.StatusInternalServerError)
return
}
err = h.favoritesService.AddToFavoritesWithInfo(userID, mediaID, mediaType, mediaInfo)
if err != nil {
http.Error(w, "Failed to add to favorites: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Added to favorites successfully",
})
}
func (h *FavoritesHandler) 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.StatusUnauthorized)
return
}
vars := mux.Vars(r)
mediaID := vars["id"]
mediaType := r.URL.Query().Get("type")
if mediaID == "" {
http.Error(w, "Media ID is required", http.StatusBadRequest)
return
}
if mediaType == "" {
mediaType = "movie" // По умолчанию фильм для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
err := h.favoritesService.RemoveFromFavorites(userID, mediaID, mediaType)
if err != nil {
http.Error(w, "Failed to remove from favorites: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Message: "Removed from favorites successfully",
})
}
func (h *FavoritesHandler) CheckIsFavorite(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
mediaID := vars["id"]
mediaType := r.URL.Query().Get("type")
if mediaID == "" {
http.Error(w, "Media ID is required", http.StatusBadRequest)
return
}
if mediaType == "" {
mediaType = "movie" // По умолчанию фильм для обратной совместимости
}
if mediaType != "movie" && mediaType != "tv" {
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
return
}
isFavorite, err := h.favoritesService.IsFavorite(userID, mediaID, mediaType)
if err != nil {
http.Error(w, "Failed to check favorite status: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: map[string]bool{"isFavorite": isFavorite},
})
}
// fetchMediaInfoRussian получает информацию о медиа на русском языке из TMDB
func (h *FavoritesHandler) fetchMediaInfoRussian(mediaID, mediaType string) (*models.MediaInfo, error) {
var url string
if mediaType == "movie" {
url = fmt.Sprintf("https://api.themoviedb.org/3/movie/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken)
} else {
url = fmt.Sprintf("https://api.themoviedb.org/3/tv/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken)
}
client := &http.Client{Timeout: 6 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch from TMDB: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TMDB API error: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var tmdbResponse map[string]interface{}
if err := json.Unmarshal(body, &tmdbResponse); err != nil {
return nil, fmt.Errorf("failed to parse TMDB response: %w", err)
}
mediaInfo := &models.MediaInfo{
ID: mediaID,
MediaType: mediaType,
}
// Заполняем информацию в зависимости от типа медиа
if mediaType == "movie" {
if title, ok := tmdbResponse["title"].(string); ok {
mediaInfo.Title = title
}
if originalTitle, ok := tmdbResponse["original_title"].(string); ok {
mediaInfo.OriginalTitle = originalTitle
}
if releaseDate, ok := tmdbResponse["release_date"].(string); ok {
mediaInfo.ReleaseDate = releaseDate
}
} else {
if name, ok := tmdbResponse["name"].(string); ok {
mediaInfo.Title = name
}
if originalName, ok := tmdbResponse["original_name"].(string); ok {
mediaInfo.OriginalTitle = originalName
}
if firstAirDate, ok := tmdbResponse["first_air_date"].(string); ok {
mediaInfo.FirstAirDate = firstAirDate
}
}
// Общие поля
if overview, ok := tmdbResponse["overview"].(string); ok {
mediaInfo.Overview = overview
}
if posterPath, ok := tmdbResponse["poster_path"].(string); ok {
mediaInfo.PosterPath = posterPath
}
if backdropPath, ok := tmdbResponse["backdrop_path"].(string); ok {
mediaInfo.BackdropPath = backdropPath
}
if voteAverage, ok := tmdbResponse["vote_average"].(float64); ok {
mediaInfo.VoteAverage = voteAverage
}
if voteCount, ok := tmdbResponse["vote_count"].(float64); ok {
mediaInfo.VoteCount = int(voteCount)
}
if popularity, ok := tmdbResponse["popularity"].(float64); ok {
mediaInfo.Popularity = popularity
}
// Жанры
if genres, ok := tmdbResponse["genres"].([]interface{}); ok {
for _, genre := range genres {
if genreMap, ok := genre.(map[string]interface{}); ok {
if genreID, ok := genreMap["id"].(float64); ok {
mediaInfo.GenreIDs = append(mediaInfo.GenreIDs, int(genreID))
}
}
}
}
return mediaInfo, nil
}

View File

@@ -1,29 +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()
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()

View File

@@ -1,194 +1,194 @@
package handlers
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/gorilla/mux"
)
type ImagesHandler struct{}
func NewImagesHandler() *ImagesHandler { return &ImagesHandler{} }
func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
imageType := vars["type"]
imageID := vars["id"]
if imageType == "" || imageID == "" {
http.Error(w, "Type and ID are required", http.StatusBadRequest)
return
}
if imageID == "placeholder.jpg" {
h.servePlaceholder(w, r)
return
}
var imageURL string
switch imageType {
case "kp":
imageURL = fmt.Sprintf("https://kinopoiskapiunofficial.tech/images/posters/kp/%s.jpg", imageID)
case "kp_small":
imageURL = fmt.Sprintf("https://kinopoiskapiunofficial.tech/images/posters/kp_small/%s.jpg", imageID)
case "kp_big":
imageURL = fmt.Sprintf("https://kinopoiskapiunofficial.tech/images/posters/kp_big/%s.jpg", imageID)
default:
http.Error(w, "Invalid image type. Use: kp, kp_small, kp_big", http.StatusBadRequest)
return
}
client := &http.Client{Timeout: 12 * time.Second}
// Подготовим несколько вариантов заголовков для обхода ограничений источников
buildRequest := func(targetURL string, attempt int) (*http.Request, error) {
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
return nil, err
}
// Универсальные заголовки как у браузера
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7")
if ua := r.Header.Get("User-Agent"); ua != "" {
req.Header.Set("User-Agent", ua)
} else {
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0 Safari/537.36")
}
// Настройка Referer: для Yandex/Kinopoisk ставим kinopoisk.ru, иначе — origin URL
parsed, _ := url.Parse(targetURL)
host := strings.ToLower(parsed.Host)
switch attempt {
case 0:
if strings.Contains(host, "kinopoisk") || strings.Contains(host, "yandex") {
req.Header.Set("Referer", "https://www.kinopoisk.ru/")
} else if parsed.Scheme != "" && parsed.Host != "" {
req.Header.Set("Referer", parsed.Scheme+"://"+parsed.Host+"/")
}
case 1:
// Без Referer
default:
// Оставляем как есть
}
return req, nil
}
// До 2-х попыток: с реферером источника и без реферера
var resp *http.Response
var err error
for attempt := 0; attempt < 2; attempt++ {
var req *http.Request
req, err = buildRequest(imageURL, attempt)
if err != nil {
continue
}
resp, err = client.Do(req)
if err == nil && resp != nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
}
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")
_, err = io.Copy(w, resp.Body)
if err != nil {
h.servePlaceholder(w, r)
return
}
}
func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) {
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 == "" {
h.serveSVGPlaceholder(w, r)
return
}
file, err := os.Open(placeholderPath)
if err != nil {
h.serveSVGPlaceholder(w, r)
return
}
defer file.Close()
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")
_, 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
}
package handlers
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/gorilla/mux"
)
type ImagesHandler struct{}
func NewImagesHandler() *ImagesHandler { return &ImagesHandler{} }
func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
imageType := vars["type"]
imageID := vars["id"]
if imageType == "" || imageID == "" {
http.Error(w, "Type and ID are required", http.StatusBadRequest)
return
}
if imageID == "placeholder.jpg" {
h.servePlaceholder(w, r)
return
}
var imageURL string
switch imageType {
case "kp":
imageURL = fmt.Sprintf("https://kinopoiskapiunofficial.tech/images/posters/kp/%s.jpg", imageID)
case "kp_small":
imageURL = fmt.Sprintf("https://kinopoiskapiunofficial.tech/images/posters/kp_small/%s.jpg", imageID)
case "kp_big":
imageURL = fmt.Sprintf("https://kinopoiskapiunofficial.tech/images/posters/kp_big/%s.jpg", imageID)
default:
http.Error(w, "Invalid image type. Use: kp, kp_small, kp_big", http.StatusBadRequest)
return
}
client := &http.Client{Timeout: 12 * time.Second}
// Подготовим несколько вариантов заголовков для обхода ограничений источников
buildRequest := func(targetURL string, attempt int) (*http.Request, error) {
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
return nil, err
}
// Универсальные заголовки как у браузера
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
req.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7")
if ua := r.Header.Get("User-Agent"); ua != "" {
req.Header.Set("User-Agent", ua)
} else {
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0 Safari/537.36")
}
// Настройка Referer: для Yandex/Kinopoisk ставим kinopoisk.ru, иначе — origin URL
parsed, _ := url.Parse(targetURL)
host := strings.ToLower(parsed.Host)
switch attempt {
case 0:
if strings.Contains(host, "kinopoisk") || strings.Contains(host, "yandex") {
req.Header.Set("Referer", "https://www.kinopoisk.ru/")
} else if parsed.Scheme != "" && parsed.Host != "" {
req.Header.Set("Referer", parsed.Scheme+"://"+parsed.Host+"/")
}
case 1:
// Без Referer
default:
// Оставляем как есть
}
return req, nil
}
// До 2-х попыток: с реферером источника и без реферера
var resp *http.Response
var err error
for attempt := 0; attempt < 2; attempt++ {
var req *http.Request
req, err = buildRequest(imageURL, attempt)
if err != nil {
continue
}
resp, err = client.Do(req)
if err == nil && resp != nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close()
}
}
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")
_, err = io.Copy(w, resp.Body)
if err != nil {
h.servePlaceholder(w, r)
return
}
}
func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) {
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 == "" {
h.serveSVGPlaceholder(w, r)
return
}
file, err := os.Open(placeholderPath)
if err != nil {
h.serveSVGPlaceholder(w, r)
return
}
defer file.Close()
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")
_, 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
}

View File

@@ -1,35 +1,35 @@
package handlers
import (
"net/http"
)
// GetLanguage extracts the lang parameter from request and returns it with default "ru"
// Supports both "lang" and "language" query parameters
// Valid values: "ru", "en"
// Default: "ru"
func GetLanguage(r *http.Request) string {
// Check "lang" parameter first (our new standard)
lang := r.URL.Query().Get("lang")
// Fall back to "language" for backward compatibility
if lang == "" {
lang = r.URL.Query().Get("language")
}
// Default to "ru" if not specified
if lang == "" {
return "ru-RU"
}
// Convert short codes to TMDB format
switch lang {
case "en":
return "en-US"
case "ru":
return "ru-RU"
default:
// Return as-is if already in correct format
return lang
}
}
package handlers
import (
"net/http"
)
// GetLanguage extracts the lang parameter from request and returns it with default "ru"
// Supports both "lang" and "language" query parameters
// Valid values: "ru", "en"
// Default: "ru"
func GetLanguage(r *http.Request) string {
// Check "lang" parameter first (our new standard)
lang := r.URL.Query().Get("lang")
// Fall back to "language" for backward compatibility
if lang == "" {
lang = r.URL.Query().Get("language")
}
// Default to "ru" if not specified
if lang == "" {
return "ru-RU"
}
// Convert short codes to TMDB format
switch lang {
case "en":
return "en-US"
case "ru":
return "ru-RU"
default:
// Return as-is if already in correct format
return lang
}
}

View File

@@ -1,252 +1,263 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
"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 := GetLanguage(r)
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)
rawID := vars["id"]
// Support formats: "123" (old), "kp_123", "tmdb_123"
source := ""
var id int
if strings.Contains(rawID, "_") {
parts := strings.SplitN(rawID, "_", 2)
if len(parts) != 2 {
http.Error(w, "Invalid ID format", http.StatusBadRequest)
return
}
source = parts[0]
parsed, err := strconv.Atoi(parts[1])
if err != nil {
http.Error(w, "Invalid numeric ID", http.StatusBadRequest)
return
}
id = parsed
} else {
// Backward compatibility
parsed, err := strconv.Atoi(rawID)
if err != nil {
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
return
}
id = parsed
}
language := GetLanguage(r)
idType := r.URL.Query().Get("id_type")
if source == "kp" || source == "tmdb" {
idType = source
}
movie, err := h.movieService.GetByID(id, language, idType)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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) 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
}
package handlers
import (
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
"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 := GetLanguage(r)
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)
rawID := vars["id"]
// Support formats: "123" (old), "kp_123", "tmdb_123"
source := ""
var id int
if strings.Contains(rawID, "_") {
parts := strings.SplitN(rawID, "_", 2)
if len(parts) != 2 {
http.Error(w, "Invalid ID format", http.StatusBadRequest)
return
}
source = parts[0]
parsed, err := strconv.Atoi(parts[1])
if err != nil {
http.Error(w, "Invalid numeric ID", http.StatusBadRequest)
return
}
id = parsed
} else {
// Backward compatibility
parsed, err := strconv.Atoi(rawID)
if err != nil {
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
return
}
id = parsed
}
language := GetLanguage(r)
idType := r.URL.Query().Get("id_type")
if source == "kp" || source == "tmdb" {
idType = source
}
movie, err := h.movieService.GetByID(id, language, idType)
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 := GetLanguage(r)
region := r.URL.Query().Get("region")
log.Printf("[Handler] Popular request: page=%d, language=%s, region=%s", page, language, region)
movies, err := h.movieService.GetPopular(page, language, region)
if err != nil {
log.Printf("[Handler] Popular error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[Handler] Popular response: %d results", len(movies.Results))
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 := GetLanguage(r)
region := r.URL.Query().Get("region")
log.Printf("[Handler] TopRated request: page=%d, language=%s, region=%s", page, language, region)
movies, err := h.movieService.GetTopRated(page, language, region)
if err != nil {
log.Printf("[Handler] TopRated error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("[Handler] TopRated response: %d results", len(movies.Results))
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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) 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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,151 +1,151 @@
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
}
reactionType, err := h.reactionsService.GetMyReaction(userID, mediaType, mediaID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if reactionType == "" {
json.NewEncoder(w).Encode(map[string]interface{}{})
} else {
json.NewEncoder(w).Encode(map[string]string{"type": reactionType})
}
}
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
}
if err := h.reactionsService.SetReaction(userID, mediaType, mediaID, request.Type); 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
}
if err := h.reactionsService.RemoveReaction(userID, mediaType, mediaID); 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})
}
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
}
reactionType, err := h.reactionsService.GetMyReaction(userID, mediaType, mediaID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if reactionType == "" {
json.NewEncoder(w).Encode(map[string]interface{}{})
} else {
json.NewEncoder(w).Encode(map[string]string{"type": reactionType})
}
}
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
}
if err := h.reactionsService.SetReaction(userID, mediaType, mediaID, request.Type); 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
}
if err := h.reactionsService.RemoveReaction(userID, mediaType, mediaID); 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})
}

View File

@@ -1,89 +1,89 @@
package handlers
import (
"encoding/json"
"net/http"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type SearchHandler struct {
tmdbService *services.TMDBService
kpService *services.KinopoiskService
}
func NewSearchHandler(tmdbService *services.TMDBService, kpService *services.KinopoiskService) *SearchHandler {
return &SearchHandler{
tmdbService: tmdbService,
kpService: kpService,
}
}
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 := GetLanguage(r)
if services.ShouldUseKinopoisk(language) {
if h.kpService == nil {
http.Error(w, "Kinopoisk service is not configured", http.StatusBadGateway)
return
}
kpSearch, err := h.kpService.SearchFilms(query, page)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
tmdbResp := services.MapKPSearchToTMDBResponse(kpSearch)
multiResults := make([]models.MultiSearchResult, 0)
for _, movie := range tmdbResp.Results {
multiResults = append(multiResults, models.MultiSearchResult{
ID: movie.ID,
MediaType: "movie",
Title: movie.Title,
OriginalTitle: movie.OriginalTitle,
Overview: movie.Overview,
PosterPath: movie.PosterPath,
BackdropPath: movie.BackdropPath,
ReleaseDate: movie.ReleaseDate,
VoteAverage: movie.VoteAverage,
VoteCount: movie.VoteCount,
Popularity: movie.Popularity,
Adult: movie.Adult,
OriginalLanguage: movie.OriginalLanguage,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: models.MultiSearchResponse{
Page: page,
Results: multiResults,
TotalPages: tmdbResp.TotalPages,
TotalResults: tmdbResp.TotalResults,
},
})
return
}
// EN/прочие языки — TMDB
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,
})
}
package handlers
import (
"encoding/json"
"net/http"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type SearchHandler struct {
tmdbService *services.TMDBService
kpService *services.KinopoiskService
}
func NewSearchHandler(tmdbService *services.TMDBService, kpService *services.KinopoiskService) *SearchHandler {
return &SearchHandler{
tmdbService: tmdbService,
kpService: kpService,
}
}
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 := GetLanguage(r)
if services.ShouldUseKinopoisk(language) {
if h.kpService == nil {
http.Error(w, "Kinopoisk service is not configured", http.StatusBadGateway)
return
}
kpSearch, err := h.kpService.SearchFilms(query, page)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
tmdbResp := services.MapKPSearchToTMDBResponse(kpSearch)
multiResults := make([]models.MultiSearchResult, 0)
for _, movie := range tmdbResp.Results {
multiResults = append(multiResults, models.MultiSearchResult{
ID: movie.ID,
MediaType: "movie",
Title: movie.Title,
OriginalTitle: movie.OriginalTitle,
Overview: movie.Overview,
PosterPath: movie.PosterPath,
BackdropPath: movie.BackdropPath,
ReleaseDate: movie.ReleaseDate,
VoteAverage: movie.VoteAverage,
VoteCount: movie.VoteCount,
Popularity: movie.Popularity,
Adult: movie.Adult,
OriginalLanguage: movie.OriginalLanguage,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{
Success: true,
Data: models.MultiSearchResponse{
Page: page,
Results: multiResults,
TotalPages: tmdbResp.TotalPages,
TotalResults: tmdbResp.TotalResults,
},
})
return
}
// EN/прочие языки — TMDB
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,
})
}

View File

@@ -1,367 +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,
})
}
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,
})
}

View File

@@ -1,233 +1,233 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"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 := GetLanguage(r)
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)
rawID := vars["id"]
// Support formats: "123" (old), "kp_123", "tmdb_123"
source := ""
var id int
if strings.Contains(rawID, "_") {
parts := strings.SplitN(rawID, "_", 2)
if len(parts) != 2 {
http.Error(w, "Invalid ID format", http.StatusBadRequest)
return
}
source = parts[0]
parsed, err := strconv.Atoi(parts[1])
if err != nil {
http.Error(w, "Invalid numeric ID", http.StatusBadRequest)
return
}
id = parsed
} else {
// Backward compatibility
parsed, err := strconv.Atoi(rawID)
if err != nil {
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
return
}
id = parsed
}
language := GetLanguage(r)
idType := r.URL.Query().Get("id_type")
if source == "kp" || source == "tmdb" {
idType = source
}
tvShow, err := h.tvService.GetByID(id, language, idType)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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,
})
}
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"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 := GetLanguage(r)
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)
rawID := vars["id"]
// Support formats: "123" (old), "kp_123", "tmdb_123"
source := ""
var id int
if strings.Contains(rawID, "_") {
parts := strings.SplitN(rawID, "_", 2)
if len(parts) != 2 {
http.Error(w, "Invalid ID format", http.StatusBadRequest)
return
}
source = parts[0]
parsed, err := strconv.Atoi(parts[1])
if err != nil {
http.Error(w, "Invalid numeric ID", http.StatusBadRequest)
return
}
id = parsed
} else {
// Backward compatibility
parsed, err := strconv.Atoi(rawID)
if err != nil {
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
return
}
id = parsed
}
language := GetLanguage(r)
idType := r.URL.Query().Get("id_type")
if source == "kp" || source == "tmdb" {
idType = source
}
tvShow, err := h.tvService.GetByID(id, language, idType)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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 := GetLanguage(r)
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,
})
}

View File

@@ -1,228 +1,228 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type UnifiedHandler struct {
tmdb *services.TMDBService
kp *services.KinopoiskService
}
func NewUnifiedHandler(tmdb *services.TMDBService, kp *services.KinopoiskService) *UnifiedHandler {
return &UnifiedHandler{tmdb: tmdb, kp: kp}
}
// Parse source ID of form "kp_123" or "tmdb_456"
func parseSourceID(raw string) (source string, id int, err error) {
parts := strings.SplitN(raw, "_", 2)
if len(parts) != 2 {
return "", 0, strconv.ErrSyntax
}
src := strings.ToLower(parts[0])
if src != "kp" && src != "tmdb" {
return "", 0, strconv.ErrSyntax
}
num, err := strconv.Atoi(parts[1])
if err != nil {
return "", 0, err
}
return src, num, nil
}
func (h *UnifiedHandler) GetMovie(w http.ResponseWriter, r *http.Request) {
start := time.Now()
vars := muxVars(r)
rawID := vars["id"]
source, id, err := parseSourceID(rawID)
if err != nil {
writeUnifiedError(w, http.StatusBadRequest, "invalid SOURCE_ID format", start, "")
return
}
language := GetLanguage(r)
var data *models.UnifiedContent
if source == "kp" {
if h.kp == nil {
writeUnifiedError(w, http.StatusBadGateway, "Kinopoisk service not configured", start, source)
return
}
kpFilm, err := h.kp.GetFilmByKinopoiskId(id)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
data = services.MapKPToUnified(kpFilm)
// Обогащаем только externalIds.tmdb через /find (берем только поле id)
if kpFilm.ImdbId != "" {
if tmdbID, fErr := h.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, "movie", GetLanguage(r)); fErr == nil {
data.ExternalIDs.TMDB = &tmdbID
}
}
} else {
// tmdb
movie, err := h.tmdb.GetMovie(id, language)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
ext, _ := h.tmdb.GetMovieExternalIDs(id)
data = services.MapTMDBToUnifiedMovie(movie, ext)
}
writeUnifiedOK(w, data, start, source, "")
}
func (h *UnifiedHandler) GetTV(w http.ResponseWriter, r *http.Request) {
start := time.Now()
vars := muxVars(r)
rawID := vars["id"]
source, id, err := parseSourceID(rawID)
if err != nil {
writeUnifiedError(w, http.StatusBadRequest, "invalid SOURCE_ID format", start, "")
return
}
language := GetLanguage(r)
var data *models.UnifiedContent
if source == "kp" {
if h.kp == nil {
writeUnifiedError(w, http.StatusBadGateway, "Kinopoisk service not configured", start, source)
return
}
kpFilm, err := h.kp.GetFilmByKinopoiskId(id)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
data = services.MapKPToUnified(kpFilm)
if kpFilm.ImdbId != "" {
if tmdbID, fErr := h.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, "tv", GetLanguage(r)); fErr == nil {
data.ExternalIDs.TMDB = &tmdbID
}
}
} else {
tv, err := h.tmdb.GetTVShow(id, language)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
ext, _ := h.tmdb.GetTVExternalIDs(id)
data = services.MapTMDBTVToUnified(tv, ext)
}
writeUnifiedOK(w, data, start, source, "")
}
func (h *UnifiedHandler) Search(w http.ResponseWriter, r *http.Request) {
start := time.Now()
query := r.URL.Query().Get("query")
if strings.TrimSpace(query) == "" {
writeUnifiedError(w, http.StatusBadRequest, "query is required", start, "")
return
}
source := strings.ToLower(r.URL.Query().Get("source")) // kp|tmdb
page := getIntQuery(r, "page", 1)
language := GetLanguage(r)
if source != "kp" && source != "tmdb" {
writeUnifiedError(w, http.StatusBadRequest, "source must be 'kp' or 'tmdb'", start, "")
return
}
if source == "kp" {
if h.kp == nil {
writeUnifiedError(w, http.StatusBadGateway, "Kinopoisk service not configured", start, source)
return
}
kpSearch, err := h.kp.SearchFilms(query, page)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
items := services.MapKPSearchToUnifiedItems(kpSearch)
// Обогащаем результаты поиска TMDB ID через получение полной информации о фильмах
if h.tmdb != nil {
for i := range items {
if kpID, err := strconv.Atoi(items[i].ID); err == nil {
if kpFilm, err := h.kp.GetFilmByKinopoiskId(kpID); err == nil && kpFilm.ImdbId != "" {
items[i].ExternalIDs.IMDb = kpFilm.ImdbId
mediaType := "movie"
if items[i].Type == "tv" {
mediaType = "tv"
}
if tmdbID, err := h.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, mediaType, "ru-RU"); err == nil {
items[i].ExternalIDs.TMDB = &tmdbID
}
}
}
}
}
resp := models.UnifiedSearchResponse{
Success: true,
Data: items,
Source: source,
Pagination: models.UnifiedPagination{Page: page, TotalPages: kpSearch.PagesCount, TotalResults: kpSearch.SearchFilmsCountResult, PageSize: len(items)},
Metadata: models.UnifiedMetadata{FetchedAt: time.Now(), APIVersion: "3.0", ResponseTime: time.Since(start).Milliseconds(), Query: query},
}
writeJSON(w, http.StatusOK, resp)
return
}
// TMDB multi search
multi, err := h.tmdb.SearchMulti(query, page, language)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
items := services.MapTMDBMultiToUnifiedItems(multi)
resp := models.UnifiedSearchResponse{
Success: true,
Data: items,
Source: source,
Pagination: models.UnifiedPagination{Page: multi.Page, TotalPages: multi.TotalPages, TotalResults: multi.TotalResults, PageSize: len(items)},
Metadata: models.UnifiedMetadata{FetchedAt: time.Now(), APIVersion: "3.0", ResponseTime: time.Since(start).Milliseconds(), Query: query},
}
writeJSON(w, http.StatusOK, resp)
}
func writeUnifiedOK(w http.ResponseWriter, data *models.UnifiedContent, start time.Time, source string, query string) {
resp := models.UnifiedAPIResponse{
Success: true,
Data: data,
Source: source,
Metadata: models.UnifiedMetadata{
FetchedAt: time.Now(),
APIVersion: "3.0",
ResponseTime: time.Since(start).Milliseconds(),
Query: query,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func writeUnifiedError(w http.ResponseWriter, code int, message string, start time.Time, source string) {
resp := models.UnifiedAPIResponse{
Success: false,
Error: message,
Source: source,
Metadata: models.UnifiedMetadata{
FetchedAt: time.Now(),
APIVersion: "3.0",
ResponseTime: time.Since(start).Milliseconds(),
},
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(resp)
}
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"neomovies-api/pkg/models"
"neomovies-api/pkg/services"
)
type UnifiedHandler struct {
tmdb *services.TMDBService
kp *services.KinopoiskService
}
func NewUnifiedHandler(tmdb *services.TMDBService, kp *services.KinopoiskService) *UnifiedHandler {
return &UnifiedHandler{tmdb: tmdb, kp: kp}
}
// Parse source ID of form "kp_123" or "tmdb_456"
func parseSourceID(raw string) (source string, id int, err error) {
parts := strings.SplitN(raw, "_", 2)
if len(parts) != 2 {
return "", 0, strconv.ErrSyntax
}
src := strings.ToLower(parts[0])
if src != "kp" && src != "tmdb" {
return "", 0, strconv.ErrSyntax
}
num, err := strconv.Atoi(parts[1])
if err != nil {
return "", 0, err
}
return src, num, nil
}
func (h *UnifiedHandler) GetMovie(w http.ResponseWriter, r *http.Request) {
start := time.Now()
vars := muxVars(r)
rawID := vars["id"]
source, id, err := parseSourceID(rawID)
if err != nil {
writeUnifiedError(w, http.StatusBadRequest, "invalid SOURCE_ID format", start, "")
return
}
language := GetLanguage(r)
var data *models.UnifiedContent
if source == "kp" {
if h.kp == nil {
writeUnifiedError(w, http.StatusBadGateway, "Kinopoisk service not configured", start, source)
return
}
kpFilm, err := h.kp.GetFilmByKinopoiskId(id)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
data = services.MapKPToUnified(kpFilm)
// Обогащаем только externalIds.tmdb через /find (берем только поле id)
if kpFilm.ImdbId != "" {
if tmdbID, fErr := h.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, "movie", GetLanguage(r)); fErr == nil {
data.ExternalIDs.TMDB = &tmdbID
}
}
} else {
// tmdb
movie, err := h.tmdb.GetMovie(id, language)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
ext, _ := h.tmdb.GetMovieExternalIDs(id)
data = services.MapTMDBToUnifiedMovie(movie, ext)
}
writeUnifiedOK(w, data, start, source, "")
}
func (h *UnifiedHandler) GetTV(w http.ResponseWriter, r *http.Request) {
start := time.Now()
vars := muxVars(r)
rawID := vars["id"]
source, id, err := parseSourceID(rawID)
if err != nil {
writeUnifiedError(w, http.StatusBadRequest, "invalid SOURCE_ID format", start, "")
return
}
language := GetLanguage(r)
var data *models.UnifiedContent
if source == "kp" {
if h.kp == nil {
writeUnifiedError(w, http.StatusBadGateway, "Kinopoisk service not configured", start, source)
return
}
kpFilm, err := h.kp.GetFilmByKinopoiskId(id)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
data = services.MapKPToUnified(kpFilm)
if kpFilm.ImdbId != "" {
if tmdbID, fErr := h.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, "tv", GetLanguage(r)); fErr == nil {
data.ExternalIDs.TMDB = &tmdbID
}
}
} else {
tv, err := h.tmdb.GetTVShow(id, language)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
ext, _ := h.tmdb.GetTVExternalIDs(id)
data = services.MapTMDBTVToUnified(tv, ext)
}
writeUnifiedOK(w, data, start, source, "")
}
func (h *UnifiedHandler) Search(w http.ResponseWriter, r *http.Request) {
start := time.Now()
query := r.URL.Query().Get("query")
if strings.TrimSpace(query) == "" {
writeUnifiedError(w, http.StatusBadRequest, "query is required", start, "")
return
}
source := strings.ToLower(r.URL.Query().Get("source")) // kp|tmdb
page := getIntQuery(r, "page", 1)
language := GetLanguage(r)
if source != "kp" && source != "tmdb" {
writeUnifiedError(w, http.StatusBadRequest, "source must be 'kp' or 'tmdb'", start, "")
return
}
if source == "kp" {
if h.kp == nil {
writeUnifiedError(w, http.StatusBadGateway, "Kinopoisk service not configured", start, source)
return
}
kpSearch, err := h.kp.SearchFilms(query, page)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
items := services.MapKPSearchToUnifiedItems(kpSearch)
// Обогащаем результаты поиска TMDB ID через получение полной информации о фильмах
if h.tmdb != nil {
for i := range items {
if kpID, err := strconv.Atoi(items[i].ID); err == nil {
if kpFilm, err := h.kp.GetFilmByKinopoiskId(kpID); err == nil && kpFilm.ImdbId != "" {
items[i].ExternalIDs.IMDb = kpFilm.ImdbId
mediaType := "movie"
if items[i].Type == "tv" {
mediaType = "tv"
}
if tmdbID, err := h.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, mediaType, "ru-RU"); err == nil {
items[i].ExternalIDs.TMDB = &tmdbID
}
}
}
}
}
resp := models.UnifiedSearchResponse{
Success: true,
Data: items,
Source: source,
Pagination: models.UnifiedPagination{Page: page, TotalPages: kpSearch.PagesCount, TotalResults: kpSearch.SearchFilmsCountResult, PageSize: len(items)},
Metadata: models.UnifiedMetadata{FetchedAt: time.Now(), APIVersion: "3.0", ResponseTime: time.Since(start).Milliseconds(), Query: query},
}
writeJSON(w, http.StatusOK, resp)
return
}
// TMDB multi search
multi, err := h.tmdb.SearchMulti(query, page, language)
if err != nil {
writeUnifiedError(w, http.StatusBadGateway, err.Error(), start, source)
return
}
items := services.MapTMDBMultiToUnifiedItems(multi)
resp := models.UnifiedSearchResponse{
Success: true,
Data: items,
Source: source,
Pagination: models.UnifiedPagination{Page: multi.Page, TotalPages: multi.TotalPages, TotalResults: multi.TotalResults, PageSize: len(items)},
Metadata: models.UnifiedMetadata{FetchedAt: time.Now(), APIVersion: "3.0", ResponseTime: time.Since(start).Milliseconds(), Query: query},
}
writeJSON(w, http.StatusOK, resp)
}
func writeUnifiedOK(w http.ResponseWriter, data *models.UnifiedContent, start time.Time, source string, query string) {
resp := models.UnifiedAPIResponse{
Success: true,
Data: data,
Source: source,
Metadata: models.UnifiedMetadata{
FetchedAt: time.Now(),
APIVersion: "3.0",
ResponseTime: time.Since(start).Milliseconds(),
Query: query,
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func writeUnifiedError(w http.ResponseWriter, code int, message string, start time.Time, source string) {
resp := models.UnifiedAPIResponse{
Success: false,
Error: message,
Source: source,
Metadata: models.UnifiedMetadata{
FetchedAt: time.Now(),
APIVersion: "3.0",
ResponseTime: time.Since(start).Milliseconds(),
},
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(resp)
}

View File

@@ -1,23 +1,23 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/gorilla/mux"
)
func muxVars(r *http.Request) map[string]string { return mux.Vars(r) }
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type metaEnvelope struct {
FetchedAt time.Time `json:"fetchedAt"`
APIVersion string `json:"apiVersion"`
ResponseTime int64 `json:"responseTime"`
}
package handlers
import (
"encoding/json"
"net/http"
"time"
"github.com/gorilla/mux"
)
func muxVars(r *http.Request) map[string]string { return mux.Vars(r) }
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type metaEnvelope struct {
FetchedAt time.Time `json:"fetchedAt"`
APIVersion string `json:"apiVersion"`
ResponseTime int64 `json:"responseTime"`
}