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,78 +1,78 @@
package config
import (
"log"
"os"
)
type Config struct {
MongoURI string
MongoDBName string
TMDBAccessToken string
JWTSecret string
Port string
BaseURL string
NodeEnv string
GmailUser string
GmailPassword string
LumexURL string
AllohaToken string
RedAPIBaseURL string
RedAPIKey string
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
FrontendURL string
VibixHost string
VibixToken string
KPAPIKey string
HDVBToken string
KPAPIBaseURL string
}
func New() *Config {
mongoURI := getMongoURI()
return &Config{
MongoURI: mongoURI,
MongoDBName: getEnv(EnvMongoDBName, DefaultMongoDBName),
TMDBAccessToken: getEnv(EnvTMDBAccessToken, ""),
JWTSecret: getEnv(EnvJWTSecret, DefaultJWTSecret),
Port: getEnv(EnvPort, DefaultPort),
BaseURL: getEnv(EnvBaseURL, DefaultBaseURL),
NodeEnv: getEnv(EnvNodeEnv, DefaultNodeEnv),
GmailUser: getEnv(EnvGmailUser, ""),
GmailPassword: getEnv(EnvGmailPassword, ""),
LumexURL: getEnv(EnvLumexURL, ""),
AllohaToken: getEnv(EnvAllohaToken, ""),
RedAPIBaseURL: getEnv(EnvRedAPIBaseURL, DefaultRedAPIBase),
RedAPIKey: getEnv(EnvRedAPIKey, ""),
GoogleClientID: getEnv(EnvGoogleClientID, ""),
GoogleClientSecret: getEnv(EnvGoogleClientSecret, ""),
GoogleRedirectURL: getEnv(EnvGoogleRedirectURL, ""),
FrontendURL: getEnv(EnvFrontendURL, ""),
VibixHost: getEnv(EnvVibixHost, DefaultVibixHost),
VibixToken: getEnv(EnvVibixToken, ""),
KPAPIKey: getEnv(EnvKPAPIKey, ""),
HDVBToken: getEnv(EnvHDVBToken, ""),
KPAPIBaseURL: getEnv("KPAPI_BASE_URL", DefaultKPAPIBase),
}
}
func getMongoURI() string {
for _, envVar := range []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} {
if value := os.Getenv(envVar); value != "" {
log.Printf("DEBUG: Using %s for MongoDB connection", envVar)
return value
}
}
log.Printf("DEBUG: No MongoDB URI environment variable found")
return ""
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
package config
import (
"log"
"os"
)
type Config struct {
MongoURI string
MongoDBName string
TMDBAccessToken string
JWTSecret string
Port string
BaseURL string
NodeEnv string
GmailUser string
GmailPassword string
LumexURL string
AllohaToken string
RedAPIBaseURL string
RedAPIKey string
GoogleClientID string
GoogleClientSecret string
GoogleRedirectURL string
FrontendURL string
VibixHost string
VibixToken string
KPAPIKey string
HDVBToken string
KPAPIBaseURL string
}
func New() *Config {
mongoURI := getMongoURI()
return &Config{
MongoURI: mongoURI,
MongoDBName: getEnv(EnvMongoDBName, DefaultMongoDBName),
TMDBAccessToken: getEnv(EnvTMDBAccessToken, ""),
JWTSecret: getEnv(EnvJWTSecret, DefaultJWTSecret),
Port: getEnv(EnvPort, DefaultPort),
BaseURL: getEnv(EnvBaseURL, DefaultBaseURL),
NodeEnv: getEnv(EnvNodeEnv, DefaultNodeEnv),
GmailUser: getEnv(EnvGmailUser, ""),
GmailPassword: getEnv(EnvGmailPassword, ""),
LumexURL: getEnv(EnvLumexURL, ""),
AllohaToken: getEnv(EnvAllohaToken, ""),
RedAPIBaseURL: getEnv(EnvRedAPIBaseURL, DefaultRedAPIBase),
RedAPIKey: getEnv(EnvRedAPIKey, ""),
GoogleClientID: getEnv(EnvGoogleClientID, ""),
GoogleClientSecret: getEnv(EnvGoogleClientSecret, ""),
GoogleRedirectURL: getEnv(EnvGoogleRedirectURL, ""),
FrontendURL: getEnv(EnvFrontendURL, ""),
VibixHost: getEnv(EnvVibixHost, DefaultVibixHost),
VibixToken: getEnv(EnvVibixToken, ""),
KPAPIKey: getEnv(EnvKPAPIKey, ""),
HDVBToken: getEnv(EnvHDVBToken, ""),
KPAPIBaseURL: getEnv("KPAPI_BASE_URL", DefaultKPAPIBase),
}
}
func getMongoURI() string {
for _, envVar := range []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} {
if value := os.Getenv(envVar); value != "" {
log.Printf("DEBUG: Using %s for MongoDB connection", envVar)
return value
}
}
log.Printf("DEBUG: No MongoDB URI environment variable found")
return ""
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View File

@@ -1,39 +1,39 @@
package config
const (
// Environment variable keys
EnvTMDBAccessToken = "TMDB_ACCESS_TOKEN"
EnvJWTSecret = "JWT_SECRET"
EnvPort = "PORT"
EnvBaseURL = "BASE_URL"
EnvNodeEnv = "NODE_ENV"
EnvGmailUser = "GMAIL_USER"
EnvGmailPassword = "GMAIL_APP_PASSWORD"
EnvLumexURL = "LUMEX_URL"
EnvAllohaToken = "ALLOHA_TOKEN"
EnvRedAPIBaseURL = "REDAPI_BASE_URL"
EnvRedAPIKey = "REDAPI_KEY"
EnvMongoDBName = "MONGO_DB_NAME"
EnvGoogleClientID = "GOOGLE_CLIENT_ID"
EnvGoogleClientSecret = "GOOGLE_CLIENT_SECRET"
EnvGoogleRedirectURL = "GOOGLE_REDIRECT_URL"
EnvFrontendURL = "FRONTEND_URL"
EnvVibixHost = "VIBIX_HOST"
EnvVibixToken = "VIBIX_TOKEN"
EnvKPAPIKey = "KPAPI_KEY"
EnvHDVBToken = "HDVB_TOKEN"
// Default values
DefaultJWTSecret = "your-secret-key"
DefaultPort = "3000"
DefaultBaseURL = "http://localhost:3000"
DefaultNodeEnv = "development"
DefaultRedAPIBase = "http://redapi.cfhttp.top"
DefaultMongoDBName = "database"
DefaultVibixHost = "https://vibix.org"
DefaultKPAPIBase = "https://kinopoiskapiunofficial.tech/api"
// Static constants
TMDBImageBaseURL = "https://image.tmdb.org/t/p"
CubAPIBaseURL = "https://cub.rip/api"
)
package config
const (
// Environment variable keys
EnvTMDBAccessToken = "TMDB_ACCESS_TOKEN"
EnvJWTSecret = "JWT_SECRET"
EnvPort = "PORT"
EnvBaseURL = "BASE_URL"
EnvNodeEnv = "NODE_ENV"
EnvGmailUser = "GMAIL_USER"
EnvGmailPassword = "GMAIL_APP_PASSWORD"
EnvLumexURL = "LUMEX_URL"
EnvAllohaToken = "ALLOHA_TOKEN"
EnvRedAPIBaseURL = "REDAPI_BASE_URL"
EnvRedAPIKey = "REDAPI_KEY"
EnvMongoDBName = "MONGO_DB_NAME"
EnvGoogleClientID = "GOOGLE_CLIENT_ID"
EnvGoogleClientSecret = "GOOGLE_CLIENT_SECRET"
EnvGoogleRedirectURL = "GOOGLE_REDIRECT_URL"
EnvFrontendURL = "FRONTEND_URL"
EnvVibixHost = "VIBIX_HOST"
EnvVibixToken = "VIBIX_TOKEN"
EnvKPAPIKey = "KPAPI_KEY"
EnvHDVBToken = "HDVB_TOKEN"
// Default values
DefaultJWTSecret = "your-secret-key"
DefaultPort = "3000"
DefaultBaseURL = "http://localhost:3000"
DefaultNodeEnv = "development"
DefaultRedAPIBase = "http://redapi.cfhttp.top"
DefaultMongoDBName = "database"
DefaultVibixHost = "https://vibix.org"
DefaultKPAPIBase = "https://kinopoiskapiunofficial.tech/api"
// Static constants
TMDBImageBaseURL = "https://image.tmdb.org/t/p"
CubAPIBaseURL = "https://cub.rip/api"
)

View File

@@ -1,41 +1,41 @@
package database
import (
"context"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var client *mongo.Client
func Connect(uri, dbName string) (*mongo.Database, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var err error
client, err = mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
return nil, err
}
if err = client.Ping(ctx, nil); err != nil {
return nil, err
}
return client.Database(dbName), nil
}
func Disconnect() error {
if client == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return client.Disconnect(ctx)
}
func GetClient() *mongo.Client { return client }
package database
import (
"context"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var client *mongo.Client
func Connect(uri, dbName string) (*mongo.Database, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var err error
client, err = mongo.Connect(ctx, options.Client().ApplyURI(uri))
if err != nil {
return nil, err
}
if err = client.Ping(ctx, nil); err != nil {
return nil, err
}
return client.Database(dbName), nil
}
func Disconnect() error {
if client == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return client.Disconnect(ctx)
}
func GetClient() *mongo.Client { return client }

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"`
}

View File

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

View File

@@ -1,24 +1,24 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Favorite struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
UserID string `json:"userId" bson:"userId"`
MediaID string `json:"mediaId" bson:"mediaId"`
MediaType string `json:"mediaType" bson:"mediaType"` // "movie" or "tv"
Title string `json:"title" bson:"title"`
PosterPath string `json:"posterPath" bson:"posterPath"`
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
}
type FavoriteRequest struct {
MediaID string `json:"mediaId" validate:"required"`
MediaType string `json:"mediaType" validate:"required,oneof=movie tv"`
Title string `json:"title" validate:"required"`
PosterPath string `json:"posterPath"`
}
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Favorite struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
UserID string `json:"userId" bson:"userId"`
MediaID string `json:"mediaId" bson:"mediaId"`
MediaType string `json:"mediaType" bson:"mediaType"` // "movie" or "tv"
Title string `json:"title" bson:"title"`
PosterPath string `json:"posterPath" bson:"posterPath"`
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
}
type FavoriteRequest struct {
MediaID string `json:"mediaId" validate:"required"`
MediaType string `json:"mediaType" validate:"required,oneof=movie tv"`
Title string `json:"title" validate:"required"`
PosterPath string `json:"posterPath"`
}

View File

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

View File

@@ -1,114 +1,114 @@
package models
import "time"
// Unified entities and response envelopes for prefixed-source API
type UnifiedGenre struct {
ID string `json:"id"`
Name string `json:"name"`
}
type UnifiedCastMember struct {
ID string `json:"id"`
Name string `json:"name"`
Character string `json:"character,omitempty"`
}
type UnifiedExternalIDs struct {
KP *int `json:"kp"`
TMDB *int `json:"tmdb"`
IMDb string `json:"imdb"`
}
type UnifiedContent struct {
ID string `json:"id"`
SourceID string `json:"sourceId"`
Title string `json:"title"`
OriginalTitle string `json:"originalTitle"`
Description string `json:"description"`
ReleaseDate string `json:"releaseDate"`
EndDate *string `json:"endDate"`
Type string `json:"type"` // movie | tv
Genres []UnifiedGenre `json:"genres"`
Rating float64 `json:"rating"`
PosterURL string `json:"posterUrl"`
BackdropURL string `json:"backdropUrl"`
Director string `json:"director"`
Cast []UnifiedCastMember `json:"cast"`
Duration int `json:"duration"`
Country string `json:"country"`
Language string `json:"language"`
Budget *int64 `json:"budget"`
Revenue *int64 `json:"revenue"`
IMDbID string `json:"imdbId"`
ExternalIDs UnifiedExternalIDs `json:"externalIds"`
// For TV shows
Seasons []UnifiedSeason `json:"seasons,omitempty"`
}
type UnifiedSeason struct {
ID string `json:"id"`
SourceID string `json:"sourceId"`
Name string `json:"name"`
SeasonNumber int `json:"seasonNumber"`
EpisodeCount int `json:"episodeCount"`
ReleaseDate string `json:"releaseDate"`
PosterURL string `json:"posterUrl"`
Episodes []UnifiedEpisode `json:"episodes,omitempty"`
}
type UnifiedEpisode struct {
ID string `json:"id"`
SourceID string `json:"sourceId"`
Name string `json:"name"`
EpisodeNumber int `json:"episodeNumber"`
SeasonNumber int `json:"seasonNumber"`
AirDate string `json:"airDate"`
Duration int `json:"duration"`
Description string `json:"description"`
StillURL string `json:"stillUrl"`
}
type UnifiedSearchItem struct {
ID string `json:"id"`
SourceID string `json:"sourceId"`
Title string `json:"title"`
Type string `json:"type"`
OriginalType string `json:"originalType,omitempty"`
ReleaseDate string `json:"releaseDate"`
PosterURL string `json:"posterUrl"`
Rating float64 `json:"rating"`
Description string `json:"description"`
ExternalIDs UnifiedExternalIDs `json:"externalIds"`
}
type UnifiedPagination struct {
Page int `json:"page"`
TotalPages int `json:"totalPages"`
TotalResults int `json:"totalResults"`
PageSize int `json:"pageSize"`
}
type UnifiedMetadata struct {
FetchedAt time.Time `json:"fetchedAt"`
APIVersion string `json:"apiVersion"`
ResponseTime int64 `json:"responseTime"`
Query string `json:"query,omitempty"`
}
type UnifiedAPIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Source string `json:"source,omitempty"`
Metadata UnifiedMetadata `json:"metadata"`
}
type UnifiedSearchResponse struct {
Success bool `json:"success"`
Data []UnifiedSearchItem `json:"data"`
Source string `json:"source"`
Pagination UnifiedPagination `json:"pagination"`
Metadata UnifiedMetadata `json:"metadata"`
}
package models
import "time"
// Unified entities and response envelopes for prefixed-source API
type UnifiedGenre struct {
ID string `json:"id"`
Name string `json:"name"`
}
type UnifiedCastMember struct {
ID string `json:"id"`
Name string `json:"name"`
Character string `json:"character,omitempty"`
}
type UnifiedExternalIDs struct {
KP *int `json:"kp"`
TMDB *int `json:"tmdb"`
IMDb string `json:"imdb"`
}
type UnifiedContent struct {
ID string `json:"id"`
SourceID string `json:"sourceId"`
Title string `json:"title"`
OriginalTitle string `json:"originalTitle"`
Description string `json:"description"`
ReleaseDate string `json:"releaseDate"`
EndDate *string `json:"endDate"`
Type string `json:"type"` // movie | tv
Genres []UnifiedGenre `json:"genres"`
Rating float64 `json:"rating"`
PosterURL string `json:"posterUrl"`
BackdropURL string `json:"backdropUrl"`
Director string `json:"director"`
Cast []UnifiedCastMember `json:"cast"`
Duration int `json:"duration"`
Country string `json:"country"`
Language string `json:"language"`
Budget *int64 `json:"budget"`
Revenue *int64 `json:"revenue"`
IMDbID string `json:"imdbId"`
ExternalIDs UnifiedExternalIDs `json:"externalIds"`
// For TV shows
Seasons []UnifiedSeason `json:"seasons,omitempty"`
}
type UnifiedSeason struct {
ID string `json:"id"`
SourceID string `json:"sourceId"`
Name string `json:"name"`
SeasonNumber int `json:"seasonNumber"`
EpisodeCount int `json:"episodeCount"`
ReleaseDate string `json:"releaseDate"`
PosterURL string `json:"posterUrl"`
Episodes []UnifiedEpisode `json:"episodes,omitempty"`
}
type UnifiedEpisode struct {
ID string `json:"id"`
SourceID string `json:"sourceId"`
Name string `json:"name"`
EpisodeNumber int `json:"episodeNumber"`
SeasonNumber int `json:"seasonNumber"`
AirDate string `json:"airDate"`
Duration int `json:"duration"`
Description string `json:"description"`
StillURL string `json:"stillUrl"`
}
type UnifiedSearchItem struct {
ID string `json:"id"`
SourceID string `json:"sourceId"`
Title string `json:"title"`
Type string `json:"type"`
OriginalType string `json:"originalType,omitempty"`
ReleaseDate string `json:"releaseDate"`
PosterURL string `json:"posterUrl"`
Rating float64 `json:"rating"`
Description string `json:"description"`
ExternalIDs UnifiedExternalIDs `json:"externalIds"`
}
type UnifiedPagination struct {
Page int `json:"page"`
TotalPages int `json:"totalPages"`
TotalResults int `json:"totalResults"`
PageSize int `json:"pageSize"`
}
type UnifiedMetadata struct {
FetchedAt time.Time `json:"fetchedAt"`
APIVersion string `json:"apiVersion"`
ResponseTime int64 `json:"responseTime"`
Query string `json:"query,omitempty"`
}
type UnifiedAPIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Source string `json:"source,omitempty"`
Metadata UnifiedMetadata `json:"metadata"`
}
type UnifiedSearchResponse struct {
Success bool `json:"success"`
Data []UnifiedSearchItem `json:"data"`
Source string `json:"source"`
Pagination UnifiedPagination `json:"pagination"`
Metadata UnifiedMetadata `json:"metadata"`
}

View File

@@ -1,69 +1,69 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type User struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
Email string `json:"email" bson:"email" validate:"required,email"`
Password string `json:"-" bson:"password" validate:"required,min=6"`
Name string `json:"name" bson:"name" validate:"required"`
Avatar string `json:"avatar" bson:"avatar"`
Favorites []string `json:"favorites" bson:"favorites"`
Verified bool `json:"verified" bson:"verified"`
VerificationCode string `json:"-" bson:"verificationCode,omitempty"`
VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"`
IsAdmin bool `json:"isAdmin" bson:"isAdmin"`
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"`
Provider string `json:"provider,omitempty" bson:"provider,omitempty"`
GoogleID string `json:"googleId,omitempty" bson:"googleId,omitempty"`
RefreshTokens []RefreshToken `json:"-" bson:"refreshTokens,omitempty"`
}
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
Name string `json:"name" validate:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
User User `json:"user"`
}
type VerifyEmailRequest struct {
Email string `json:"email" validate:"required,email"`
Code string `json:"code" validate:"required"`
}
type ResendCodeRequest struct {
Email string `json:"email" validate:"required,email"`
}
type RefreshToken struct {
Token string `json:"token" bson:"token"`
ExpiresAt time.Time `json:"expiresAt" bson:"expiresAt"`
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
UserAgent string `json:"userAgent,omitempty" bson:"userAgent,omitempty"`
IPAddress string `json:"ipAddress,omitempty" bson:"ipAddress,omitempty"`
}
type TokenPair struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
type RefreshTokenRequest struct {
RefreshToken string `json:"refreshToken" validate:"required"`
}
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type User struct {
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
Email string `json:"email" bson:"email" validate:"required,email"`
Password string `json:"-" bson:"password" validate:"required,min=6"`
Name string `json:"name" bson:"name" validate:"required"`
Avatar string `json:"avatar" bson:"avatar"`
Favorites []string `json:"favorites" bson:"favorites"`
Verified bool `json:"verified" bson:"verified"`
VerificationCode string `json:"-" bson:"verificationCode,omitempty"`
VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"`
IsAdmin bool `json:"isAdmin" bson:"isAdmin"`
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"`
Provider string `json:"provider,omitempty" bson:"provider,omitempty"`
GoogleID string `json:"googleId,omitempty" bson:"googleId,omitempty"`
RefreshTokens []RefreshToken `json:"-" bson:"refreshTokens,omitempty"`
}
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
type RegisterRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
Name string `json:"name" validate:"required"`
}
type AuthResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
User User `json:"user"`
}
type VerifyEmailRequest struct {
Email string `json:"email" validate:"required,email"`
Code string `json:"code" validate:"required"`
}
type ResendCodeRequest struct {
Email string `json:"email" validate:"required,email"`
}
type RefreshToken struct {
Token string `json:"token" bson:"token"`
ExpiresAt time.Time `json:"expiresAt" bson:"expiresAt"`
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
UserAgent string `json:"userAgent,omitempty" bson:"userAgent,omitempty"`
IPAddress string `json:"ipAddress,omitempty" bson:"ipAddress,omitempty"`
}
type TokenPair struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
type RefreshTokenRequest struct {
RefreshToken string `json:"refreshToken" validate:"required"`
}

View File

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

View File

@@ -1,208 +1,208 @@
package players
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"regexp"
"strconv"
"time"
)
// IframeVideoSearchResponse represents the search response from IframeVideo API
type IframeVideoSearchResponse struct {
Results []struct {
CID int `json:"cid"`
Path string `json:"path"`
Type string `json:"type"`
} `json:"results"`
}
// IframeVideoResponse represents the video response from IframeVideo API
type IframeVideoResponse struct {
Source string `json:"src"`
}
// IframeVideoPlayer implements the IframeVideo streaming service
type IframeVideoPlayer struct {
APIHost string
CDNHost string
Client *http.Client
}
// NewIframeVideoPlayer creates a new IframeVideo player instance
func NewIframeVideoPlayer() *IframeVideoPlayer {
return &IframeVideoPlayer{
APIHost: "https://iframe.video",
CDNHost: "https://videoframe.space",
Client: &http.Client{
Timeout: 8 * time.Second,
},
}
}
// GetStream gets streaming URL by Kinopoisk ID and IMDB ID
func (i *IframeVideoPlayer) GetStream(kinopoiskID, imdbID string) (*StreamResult, error) {
// First, search for content
searchResult, err := i.searchContent(kinopoiskID, imdbID)
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
// Get iframe content to extract token
token, err := i.extractToken(searchResult.Path)
if err != nil {
return nil, fmt.Errorf("token extraction failed: %w", err)
}
// Get video URL
return i.getVideoURL(searchResult.CID, token, searchResult.Type)
}
// searchContent searches for content by Kinopoisk and IMDB IDs
func (i *IframeVideoPlayer) searchContent(kinopoiskID, imdbID string) (*struct {
CID int
Path string
Type string
}, error) {
url := fmt.Sprintf("%s/api/v2/search?imdb=%s&kp=%s", i.APIHost, imdbID, kinopoiskID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
resp, err := i.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch search results: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status: %d", resp.StatusCode)
}
var searchResp IframeVideoSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if len(searchResp.Results) == 0 {
return nil, fmt.Errorf("content not found")
}
result := searchResp.Results[0]
return &struct {
CID int
Path string
Type string
}{
CID: result.CID,
Path: result.Path,
Type: result.Type,
}, nil
}
// extractToken extracts token from iframe HTML content
func (i *IframeVideoPlayer) extractToken(path string) (string, error) {
req, err := http.NewRequest("GET", path, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// Set headers similar to C# implementation
req.Header.Set("DNT", "1")
req.Header.Set("Referer", i.CDNHost+"/")
req.Header.Set("Sec-Fetch-Dest", "iframe")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-Site", "cross-site")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("sec-ch-ua", `"Google Chrome";v="113", "Chromium";v="113", "Not-A.Brand";v="24"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
resp, err := i.Client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch iframe content: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("iframe returned status: %d", resp.StatusCode)
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read iframe content: %w", err)
}
// Extract token using regex as in C# implementation
re := regexp.MustCompile(`\/[^\/]+\/([^\/]+)\/iframe`)
matches := re.FindStringSubmatch(string(content))
if len(matches) < 2 {
return "", fmt.Errorf("token not found in iframe content")
}
return matches[1], nil
}
// getVideoURL gets video URL using extracted token
func (i *IframeVideoPlayer) getVideoURL(cid int, token, mediaType string) (*StreamResult, error) {
// Create multipart form data
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
writer.WriteField("token", token)
writer.WriteField("type", mediaType)
writer.WriteField("season", "")
writer.WriteField("episode", "")
writer.WriteField("mobile", "false")
writer.WriteField("id", strconv.Itoa(cid))
writer.WriteField("qt", "480")
contentType := writer.FormDataContentType()
writer.Close()
req, err := http.NewRequest("POST", i.CDNHost+"/loadvideo", &buf)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Origin", i.CDNHost)
req.Header.Set("Referer", i.CDNHost+"/")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
resp, err := i.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch video URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("video API returned status: %d", resp.StatusCode)
}
var videoResp IframeVideoResponse
if err := json.NewDecoder(resp.Body).Decode(&videoResp); err != nil {
return nil, fmt.Errorf("failed to decode video response: %w", err)
}
if videoResp.Source == "" {
return nil, fmt.Errorf("video URL not found")
}
return &StreamResult{
Success: true,
StreamURL: videoResp.Source,
Provider: "IframeVideo",
Type: "direct",
}, nil
}
package players
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"regexp"
"strconv"
"time"
)
// IframeVideoSearchResponse represents the search response from IframeVideo API
type IframeVideoSearchResponse struct {
Results []struct {
CID int `json:"cid"`
Path string `json:"path"`
Type string `json:"type"`
} `json:"results"`
}
// IframeVideoResponse represents the video response from IframeVideo API
type IframeVideoResponse struct {
Source string `json:"src"`
}
// IframeVideoPlayer implements the IframeVideo streaming service
type IframeVideoPlayer struct {
APIHost string
CDNHost string
Client *http.Client
}
// NewIframeVideoPlayer creates a new IframeVideo player instance
func NewIframeVideoPlayer() *IframeVideoPlayer {
return &IframeVideoPlayer{
APIHost: "https://iframe.video",
CDNHost: "https://videoframe.space",
Client: &http.Client{
Timeout: 8 * time.Second,
},
}
}
// GetStream gets streaming URL by Kinopoisk ID and IMDB ID
func (i *IframeVideoPlayer) GetStream(kinopoiskID, imdbID string) (*StreamResult, error) {
// First, search for content
searchResult, err := i.searchContent(kinopoiskID, imdbID)
if err != nil {
return nil, fmt.Errorf("search failed: %w", err)
}
// Get iframe content to extract token
token, err := i.extractToken(searchResult.Path)
if err != nil {
return nil, fmt.Errorf("token extraction failed: %w", err)
}
// Get video URL
return i.getVideoURL(searchResult.CID, token, searchResult.Type)
}
// searchContent searches for content by Kinopoisk and IMDB IDs
func (i *IframeVideoPlayer) searchContent(kinopoiskID, imdbID string) (*struct {
CID int
Path string
Type string
}, error) {
url := fmt.Sprintf("%s/api/v2/search?imdb=%s&kp=%s", i.APIHost, imdbID, kinopoiskID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
resp, err := i.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch search results: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status: %d", resp.StatusCode)
}
var searchResp IframeVideoSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if len(searchResp.Results) == 0 {
return nil, fmt.Errorf("content not found")
}
result := searchResp.Results[0]
return &struct {
CID int
Path string
Type string
}{
CID: result.CID,
Path: result.Path,
Type: result.Type,
}, nil
}
// extractToken extracts token from iframe HTML content
func (i *IframeVideoPlayer) extractToken(path string) (string, error) {
req, err := http.NewRequest("GET", path, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// Set headers similar to C# implementation
req.Header.Set("DNT", "1")
req.Header.Set("Referer", i.CDNHost+"/")
req.Header.Set("Sec-Fetch-Dest", "iframe")
req.Header.Set("Sec-Fetch-Mode", "navigate")
req.Header.Set("Sec-Fetch-Site", "cross-site")
req.Header.Set("Upgrade-Insecure-Requests", "1")
req.Header.Set("sec-ch-ua", `"Google Chrome";v="113", "Chromium";v="113", "Not-A.Brand";v="24"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
resp, err := i.Client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch iframe content: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("iframe returned status: %d", resp.StatusCode)
}
content, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read iframe content: %w", err)
}
// Extract token using regex as in C# implementation
re := regexp.MustCompile(`\/[^\/]+\/([^\/]+)\/iframe`)
matches := re.FindStringSubmatch(string(content))
if len(matches) < 2 {
return "", fmt.Errorf("token not found in iframe content")
}
return matches[1], nil
}
// getVideoURL gets video URL using extracted token
func (i *IframeVideoPlayer) getVideoURL(cid int, token, mediaType string) (*StreamResult, error) {
// Create multipart form data
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
writer.WriteField("token", token)
writer.WriteField("type", mediaType)
writer.WriteField("season", "")
writer.WriteField("episode", "")
writer.WriteField("mobile", "false")
writer.WriteField("id", strconv.Itoa(cid))
writer.WriteField("qt", "480")
contentType := writer.FormDataContentType()
writer.Close()
req, err := http.NewRequest("POST", i.CDNHost+"/loadvideo", &buf)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", contentType)
req.Header.Set("Origin", i.CDNHost)
req.Header.Set("Referer", i.CDNHost+"/")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
resp, err := i.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch video URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("video API returned status: %d", resp.StatusCode)
}
var videoResp IframeVideoResponse
if err := json.NewDecoder(resp.Body).Decode(&videoResp); err != nil {
return nil, fmt.Errorf("failed to decode video response: %w", err)
}
if videoResp.Source == "" {
return nil, fmt.Errorf("video URL not found")
}
return &StreamResult{
Success: true,
StreamURL: videoResp.Source,
Provider: "IframeVideo",
Type: "direct",
}, nil
}

View File

@@ -1,81 +1,81 @@
package players
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
// RgShowsResponse represents the response from RgShows API
type RgShowsResponse struct {
Stream *struct {
URL string `json:"url"`
} `json:"stream"`
}
// RgShowsPlayer implements the RgShows streaming service
type RgShowsPlayer struct {
BaseURL string
Client *http.Client
}
// NewRgShowsPlayer creates a new RgShows player instance
func NewRgShowsPlayer() *RgShowsPlayer {
return &RgShowsPlayer{
BaseURL: "https://rgshows.com",
Client: &http.Client{
Timeout: 40 * time.Second,
},
}
}
// GetMovieStream gets streaming URL for a movie by TMDB ID
func (r *RgShowsPlayer) GetMovieStream(tmdbID string) (*StreamResult, error) {
url := fmt.Sprintf("%s/main/movie/%s", r.BaseURL, tmdbID)
return r.fetchStream(url)
}
// GetTVStream gets streaming URL for a TV show episode by TMDB ID, season and episode
func (r *RgShowsPlayer) GetTVStream(tmdbID string, season, episode int) (*StreamResult, error) {
url := fmt.Sprintf("%s/main/tv/%s/%d/%d", r.BaseURL, tmdbID, season, episode)
return r.fetchStream(url)
}
// fetchStream makes HTTP request to RgShows API and extracts stream URL
func (r *RgShowsPlayer) fetchStream(url string) (*StreamResult, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set headers similar to the C# implementation
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
resp, err := r.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch stream: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status: %d", resp.StatusCode)
}
var rgResp RgShowsResponse
if err := json.NewDecoder(resp.Body).Decode(&rgResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if rgResp.Stream == nil || rgResp.Stream.URL == "" {
return nil, fmt.Errorf("stream not found")
}
return &StreamResult{
Success: true,
StreamURL: rgResp.Stream.URL,
Provider: "RgShows",
Type: "direct",
}, nil
}
package players
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
// RgShowsResponse represents the response from RgShows API
type RgShowsResponse struct {
Stream *struct {
URL string `json:"url"`
} `json:"stream"`
}
// RgShowsPlayer implements the RgShows streaming service
type RgShowsPlayer struct {
BaseURL string
Client *http.Client
}
// NewRgShowsPlayer creates a new RgShows player instance
func NewRgShowsPlayer() *RgShowsPlayer {
return &RgShowsPlayer{
BaseURL: "https://rgshows.com",
Client: &http.Client{
Timeout: 40 * time.Second,
},
}
}
// GetMovieStream gets streaming URL for a movie by TMDB ID
func (r *RgShowsPlayer) GetMovieStream(tmdbID string) (*StreamResult, error) {
url := fmt.Sprintf("%s/main/movie/%s", r.BaseURL, tmdbID)
return r.fetchStream(url)
}
// GetTVStream gets streaming URL for a TV show episode by TMDB ID, season and episode
func (r *RgShowsPlayer) GetTVStream(tmdbID string, season, episode int) (*StreamResult, error) {
url := fmt.Sprintf("%s/main/tv/%s/%d/%d", r.BaseURL, tmdbID, season, episode)
return r.fetchStream(url)
}
// fetchStream makes HTTP request to RgShows API and extracts stream URL
func (r *RgShowsPlayer) fetchStream(url string) (*StreamResult, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set headers similar to the C# implementation
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
resp, err := r.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch stream: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status: %d", resp.StatusCode)
}
var rgResp RgShowsResponse
if err := json.NewDecoder(resp.Body).Decode(&rgResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if rgResp.Stream == nil || rgResp.Stream.URL == "" {
return nil, fmt.Errorf("stream not found")
}
return &StreamResult{
Success: true,
StreamURL: rgResp.Stream.URL,
Provider: "RgShows",
Type: "direct",
}, nil
}

View File

@@ -1,99 +1,99 @@
package players
// StreamResult represents the result of a streaming request
type StreamResult struct {
Success bool `json:"success"`
StreamURL string `json:"stream_url,omitempty"`
Provider string `json:"provider"`
Type string `json:"type"` // "direct", "iframe", "hls", etc.
Error string `json:"error,omitempty"`
}
// Player interface defines methods for streaming providers
type Player interface {
GetMovieStream(tmdbID string) (*StreamResult, error)
GetTVStream(tmdbID string, season, episode int) (*StreamResult, error)
}
// PlayersManager manages all available streaming players
type PlayersManager struct {
rgshows *RgShowsPlayer
iframevideo *IframeVideoPlayer
}
// NewPlayersManager creates a new players manager
func NewPlayersManager() *PlayersManager {
return &PlayersManager{
rgshows: NewRgShowsPlayer(),
iframevideo: NewIframeVideoPlayer(),
}
}
// GetMovieStreams tries to get movie streams from all available providers
func (pm *PlayersManager) GetMovieStreams(tmdbID string) []*StreamResult {
var results []*StreamResult
// Try RgShows
if stream, err := pm.rgshows.GetMovieStream(tmdbID); err == nil {
results = append(results, stream)
} else {
results = append(results, &StreamResult{
Success: false,
Provider: "RgShows",
Error: err.Error(),
})
}
return results
}
// GetTVStreams tries to get TV show streams from all available providers
func (pm *PlayersManager) GetTVStreams(tmdbID string, season, episode int) []*StreamResult {
var results []*StreamResult
// Try RgShows
if stream, err := pm.rgshows.GetTVStream(tmdbID, season, episode); err == nil {
results = append(results, stream)
} else {
results = append(results, &StreamResult{
Success: false,
Provider: "RgShows",
Error: err.Error(),
})
}
return results
}
// GetMovieStreamByProvider gets movie stream from specific provider
func (pm *PlayersManager) GetMovieStreamByProvider(provider, tmdbID string) (*StreamResult, error) {
switch provider {
case "rgshows":
return pm.rgshows.GetMovieStream(tmdbID)
default:
return &StreamResult{
Success: false,
Provider: provider,
Error: "provider not found",
}, nil
}
}
// GetTVStreamByProvider gets TV stream from specific provider
func (pm *PlayersManager) GetTVStreamByProvider(provider, tmdbID string, season, episode int) (*StreamResult, error) {
switch provider {
case "rgshows":
return pm.rgshows.GetTVStream(tmdbID, season, episode)
default:
return &StreamResult{
Success: false,
Provider: provider,
Error: "provider not found",
}, nil
}
}
// GetStreamWithKinopoisk gets stream using Kinopoisk ID and IMDB ID (for IframeVideo)
func (pm *PlayersManager) GetStreamWithKinopoisk(kinopoiskID, imdbID string) (*StreamResult, error) {
return pm.iframevideo.GetStream(kinopoiskID, imdbID)
}
package players
// StreamResult represents the result of a streaming request
type StreamResult struct {
Success bool `json:"success"`
StreamURL string `json:"stream_url,omitempty"`
Provider string `json:"provider"`
Type string `json:"type"` // "direct", "iframe", "hls", etc.
Error string `json:"error,omitempty"`
}
// Player interface defines methods for streaming providers
type Player interface {
GetMovieStream(tmdbID string) (*StreamResult, error)
GetTVStream(tmdbID string, season, episode int) (*StreamResult, error)
}
// PlayersManager manages all available streaming players
type PlayersManager struct {
rgshows *RgShowsPlayer
iframevideo *IframeVideoPlayer
}
// NewPlayersManager creates a new players manager
func NewPlayersManager() *PlayersManager {
return &PlayersManager{
rgshows: NewRgShowsPlayer(),
iframevideo: NewIframeVideoPlayer(),
}
}
// GetMovieStreams tries to get movie streams from all available providers
func (pm *PlayersManager) GetMovieStreams(tmdbID string) []*StreamResult {
var results []*StreamResult
// Try RgShows
if stream, err := pm.rgshows.GetMovieStream(tmdbID); err == nil {
results = append(results, stream)
} else {
results = append(results, &StreamResult{
Success: false,
Provider: "RgShows",
Error: err.Error(),
})
}
return results
}
// GetTVStreams tries to get TV show streams from all available providers
func (pm *PlayersManager) GetTVStreams(tmdbID string, season, episode int) []*StreamResult {
var results []*StreamResult
// Try RgShows
if stream, err := pm.rgshows.GetTVStream(tmdbID, season, episode); err == nil {
results = append(results, stream)
} else {
results = append(results, &StreamResult{
Success: false,
Provider: "RgShows",
Error: err.Error(),
})
}
return results
}
// GetMovieStreamByProvider gets movie stream from specific provider
func (pm *PlayersManager) GetMovieStreamByProvider(provider, tmdbID string) (*StreamResult, error) {
switch provider {
case "rgshows":
return pm.rgshows.GetMovieStream(tmdbID)
default:
return &StreamResult{
Success: false,
Provider: provider,
Error: "provider not found",
}, nil
}
}
// GetTVStreamByProvider gets TV stream from specific provider
func (pm *PlayersManager) GetTVStreamByProvider(provider, tmdbID string, season, episode int) (*StreamResult, error) {
switch provider {
case "rgshows":
return pm.rgshows.GetTVStream(tmdbID, season, episode)
default:
return &StreamResult{
Success: false,
Provider: provider,
Error: "provider not found",
}, nil
}
}
// GetStreamWithKinopoisk gets stream using Kinopoisk ID and IMDB ID (for IframeVideo)
func (pm *PlayersManager) GetStreamWithKinopoisk(kinopoiskID, imdbID string) (*StreamResult, error) {
return pm.iframevideo.GetStream(kinopoiskID, imdbID)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,184 +1,184 @@
package services
import (
"context"
"fmt"
"strconv"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models"
)
type FavoritesService struct {
db *mongo.Database
tmdb *TMDBService
}
func NewFavoritesService(db *mongo.Database, tmdb *TMDBService) *FavoritesService {
return &FavoritesService{
db: db,
tmdb: tmdb,
}
}
func (s *FavoritesService) AddToFavorites(userID, mediaID, mediaType string) error {
collection := s.db.Collection("favorites")
// Проверяем, не добавлен ли уже в избранное
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
var existingFavorite models.Favorite
err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite)
if err == nil {
// Уже в избранном
return nil
}
var title, posterPath string
// Получаем информацию из TMDB в зависимости от типа медиа
mediaIDInt, err := strconv.Atoi(mediaID)
if err != nil {
return fmt.Errorf("invalid media ID: %s", mediaID)
}
if mediaType == "movie" {
movie, err := s.tmdb.GetMovie(mediaIDInt, "en-US")
if err != nil {
return err
}
title = movie.Title
posterPath = movie.PosterPath
} else if mediaType == "tv" {
tv, err := s.tmdb.GetTVShow(mediaIDInt, "en-US")
if err != nil {
return err
}
title = tv.Name
posterPath = tv.PosterPath
} else {
return fmt.Errorf("invalid media type: %s", mediaType)
}
// Формируем полный URL для постера
if posterPath != "" {
posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath)
}
favorite := models.Favorite{
UserID: userID,
MediaID: mediaID,
MediaType: mediaType,
Title: title,
PosterPath: posterPath,
CreatedAt: time.Now(),
}
_, err = collection.InsertOne(context.Background(), favorite)
return err
}
// AddToFavoritesWithInfo adds media to favorites with provided media information
func (s *FavoritesService) AddToFavoritesWithInfo(userID, mediaID, mediaType string, mediaInfo *models.MediaInfo) error {
collection := s.db.Collection("favorites")
// Проверяем, не добавлен ли уже в избранное
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
var existingFavorite models.Favorite
err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite)
if err == nil {
// Уже в избранном
return nil
}
// Формируем полный URL для постера если он есть
posterPath := mediaInfo.PosterPath
if posterPath != "" && posterPath[0] == '/' {
posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath)
}
favorite := models.Favorite{
UserID: userID,
MediaID: mediaID,
MediaType: mediaType,
Title: mediaInfo.Title,
PosterPath: posterPath,
CreatedAt: time.Now(),
}
_, err = collection.InsertOne(context.Background(), favorite)
return err
}
func (s *FavoritesService) RemoveFromFavorites(userID, mediaID, mediaType string) error {
collection := s.db.Collection("favorites")
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
_, err := collection.DeleteOne(context.Background(), filter)
return err
}
func (s *FavoritesService) GetFavorites(userID string) ([]models.Favorite, error) {
collection := s.db.Collection("favorites")
filter := bson.M{
"userId": userID,
}
cursor, err := collection.Find(context.Background(), filter)
if err != nil {
return nil, err
}
defer cursor.Close(context.Background())
var favorites []models.Favorite
err = cursor.All(context.Background(), &favorites)
if err != nil {
return nil, err
}
// Возвращаем пустой массив вместо nil если нет избранных
if favorites == nil {
favorites = []models.Favorite{}
}
return favorites, nil
}
func (s *FavoritesService) IsFavorite(userID, mediaID, mediaType string) (bool, error) {
collection := s.db.Collection("favorites")
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
var favorite models.Favorite
err := collection.FindOne(context.Background(), filter).Decode(&favorite)
if err != nil {
if err == mongo.ErrNoDocuments {
return false, nil
}
return false, err
}
return true, nil
}
package services
import (
"context"
"fmt"
"strconv"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models"
)
type FavoritesService struct {
db *mongo.Database
tmdb *TMDBService
}
func NewFavoritesService(db *mongo.Database, tmdb *TMDBService) *FavoritesService {
return &FavoritesService{
db: db,
tmdb: tmdb,
}
}
func (s *FavoritesService) AddToFavorites(userID, mediaID, mediaType string) error {
collection := s.db.Collection("favorites")
// Проверяем, не добавлен ли уже в избранное
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
var existingFavorite models.Favorite
err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite)
if err == nil {
// Уже в избранном
return nil
}
var title, posterPath string
// Получаем информацию из TMDB в зависимости от типа медиа
mediaIDInt, err := strconv.Atoi(mediaID)
if err != nil {
return fmt.Errorf("invalid media ID: %s", mediaID)
}
if mediaType == "movie" {
movie, err := s.tmdb.GetMovie(mediaIDInt, "en-US")
if err != nil {
return err
}
title = movie.Title
posterPath = movie.PosterPath
} else if mediaType == "tv" {
tv, err := s.tmdb.GetTVShow(mediaIDInt, "en-US")
if err != nil {
return err
}
title = tv.Name
posterPath = tv.PosterPath
} else {
return fmt.Errorf("invalid media type: %s", mediaType)
}
// Формируем полный URL для постера
if posterPath != "" {
posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath)
}
favorite := models.Favorite{
UserID: userID,
MediaID: mediaID,
MediaType: mediaType,
Title: title,
PosterPath: posterPath,
CreatedAt: time.Now(),
}
_, err = collection.InsertOne(context.Background(), favorite)
return err
}
// AddToFavoritesWithInfo adds media to favorites with provided media information
func (s *FavoritesService) AddToFavoritesWithInfo(userID, mediaID, mediaType string, mediaInfo *models.MediaInfo) error {
collection := s.db.Collection("favorites")
// Проверяем, не добавлен ли уже в избранное
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
var existingFavorite models.Favorite
err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite)
if err == nil {
// Уже в избранном
return nil
}
// Формируем полный URL для постера если он есть
posterPath := mediaInfo.PosterPath
if posterPath != "" && posterPath[0] == '/' {
posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath)
}
favorite := models.Favorite{
UserID: userID,
MediaID: mediaID,
MediaType: mediaType,
Title: mediaInfo.Title,
PosterPath: posterPath,
CreatedAt: time.Now(),
}
_, err = collection.InsertOne(context.Background(), favorite)
return err
}
func (s *FavoritesService) RemoveFromFavorites(userID, mediaID, mediaType string) error {
collection := s.db.Collection("favorites")
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
_, err := collection.DeleteOne(context.Background(), filter)
return err
}
func (s *FavoritesService) GetFavorites(userID string) ([]models.Favorite, error) {
collection := s.db.Collection("favorites")
filter := bson.M{
"userId": userID,
}
cursor, err := collection.Find(context.Background(), filter)
if err != nil {
return nil, err
}
defer cursor.Close(context.Background())
var favorites []models.Favorite
err = cursor.All(context.Background(), &favorites)
if err != nil {
return nil, err
}
// Возвращаем пустой массив вместо nil если нет избранных
if favorites == nil {
favorites = []models.Favorite{}
}
return favorites, nil
}
func (s *FavoritesService) IsFavorite(userID, mediaID, mediaType string) (bool, error) {
collection := s.db.Collection("favorites")
filter := bson.M{
"userId": userID,
"mediaId": mediaID,
"mediaType": mediaType,
}
var favorite models.Favorite
err := collection.FindOne(context.Background(), filter).Decode(&favorite)
if err != nil {
if err == mongo.ErrNoDocuments {
return false, nil
}
return false, err
}
return true, nil
}

View File

@@ -1,262 +1,262 @@
package services
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
type KinopoiskService struct {
apiKey string
baseURL string
client *http.Client
}
type KPFilm struct {
KinopoiskId int `json:"kinopoiskId"`
ImdbId string `json:"imdbId"`
NameRu string `json:"nameRu"`
NameEn string `json:"nameEn"`
NameOriginal string `json:"nameOriginal"`
PosterUrl string `json:"posterUrl"`
PosterUrlPreview string `json:"posterUrlPreview"`
CoverUrl string `json:"coverUrl"`
LogoUrl string `json:"logoUrl"`
ReviewsCount int `json:"reviewsCount"`
RatingGoodReview float64 `json:"ratingGoodReview"`
RatingGoodReviewVoteCount int `json:"ratingGoodReviewVoteCount"`
RatingKinopoisk float64 `json:"ratingKinopoisk"`
RatingKinopoiskVoteCount int `json:"ratingKinopoiskVoteCount"`
RatingImdb float64 `json:"ratingImdb"`
RatingImdbVoteCount int `json:"ratingImdbVoteCount"`
RatingFilmCritics float64 `json:"ratingFilmCritics"`
RatingFilmCriticsVoteCount int `json:"ratingFilmCriticsVoteCount"`
RatingAwait float64 `json:"ratingAwait"`
RatingAwaitCount int `json:"ratingAwaitCount"`
RatingRfCritics float64 `json:"ratingRfCritics"`
RatingRfCriticsVoteCount int `json:"ratingRfCriticsVoteCount"`
WebUrl string `json:"webUrl"`
Year int `json:"year"`
FilmLength int `json:"filmLength"`
Slogan string `json:"slogan"`
Description string `json:"description"`
ShortDescription string `json:"shortDescription"`
EditorAnnotation string `json:"editorAnnotation"`
IsTicketsAvailable bool `json:"isTicketsAvailable"`
ProductionStatus string `json:"productionStatus"`
Type string `json:"type"`
RatingMpaa string `json:"ratingMpaa"`
RatingAgeLimits string `json:"ratingAgeLimits"`
HasImax bool `json:"hasImax"`
Has3D bool `json:"has3d"`
LastSync string `json:"lastSync"`
Countries []struct {
Country string `json:"country"`
} `json:"countries"`
Genres []struct {
Genre string `json:"genre"`
} `json:"genres"`
StartYear int `json:"startYear"`
EndYear int `json:"endYear"`
Serial bool `json:"serial"`
ShortFilm bool `json:"shortFilm"`
Completed bool `json:"completed"`
}
type KPSearchResponse struct {
Keyword string `json:"keyword"`
PagesCount int `json:"pagesCount"`
Films []KPFilmShort `json:"films"`
SearchFilmsCountResult int `json:"searchFilmsCountResult"`
}
type KPFilmShort struct {
FilmId int `json:"filmId"`
NameRu string `json:"nameRu"`
NameEn string `json:"nameEn"`
Type string `json:"type"`
Year string `json:"year"`
Description string `json:"description"`
FilmLength string `json:"filmLength"`
Countries []KPCountry `json:"countries"`
Genres []KPGenre `json:"genres"`
Rating string `json:"rating"`
RatingVoteCount int `json:"ratingVoteCount"`
PosterUrl string `json:"posterUrl"`
PosterUrlPreview string `json:"posterUrlPreview"`
}
type KPCountry struct {
Country string `json:"country"`
}
type KPGenre struct {
Genre string `json:"genre"`
}
type KPExternalSource struct {
Source string `json:"source"`
ID string `json:"id"`
}
func NewKinopoiskService(apiKey, baseURL string) *KinopoiskService {
return &KinopoiskService{
apiKey: apiKey,
baseURL: baseURL,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *KinopoiskService) makeRequest(endpoint string, target interface{}) error {
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return err
}
req.Header.Set("X-API-KEY", s.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Kinopoisk API error: %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(target)
}
func (s *KinopoiskService) GetFilmByKinopoiskId(id int) (*KPFilm, error) {
endpoint := fmt.Sprintf("%s/v2.2/films/%d", s.baseURL, id)
var film KPFilm
err := s.makeRequest(endpoint, &film)
return &film, err
}
func (s *KinopoiskService) GetFilmByImdbId(imdbId string) (*KPFilm, error) {
endpoint := fmt.Sprintf("%s/v2.2/films?imdbId=%s", s.baseURL, url.QueryEscape(imdbId))
var response struct {
Films []KPFilm `json:"items"`
}
err := s.makeRequest(endpoint, &response)
if err != nil {
return nil, err
}
if len(response.Films) == 0 {
return nil, fmt.Errorf("film not found")
}
return &response.Films[0], nil
}
func (s *KinopoiskService) SearchFilms(keyword string, page int) (*KPSearchResponse, error) {
endpoint := fmt.Sprintf("%s/v2.1/films/search-by-keyword?keyword=%s&page=%d", s.baseURL, url.QueryEscape(keyword), page)
var response KPSearchResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *KinopoiskService) GetExternalSources(kinopoiskId int) ([]KPExternalSource, error) {
endpoint := fmt.Sprintf("%s/v2.2/films/%d/external_sources", s.baseURL, kinopoiskId)
var response struct {
Items []KPExternalSource `json:"items"`
}
err := s.makeRequest(endpoint, &response)
if err != nil {
return nil, err
}
return response.Items, nil
}
func (s *KinopoiskService) GetTopFilms(topType string, page int) (*KPSearchResponse, error) {
endpoint := fmt.Sprintf("%s/v2.2/films/top?type=%s&page=%d", s.baseURL, topType, page)
var response struct {
PagesCount int `json:"pagesCount"`
Films []KPFilmShort `json:"films"`
}
err := s.makeRequest(endpoint, &response)
if err != nil {
return nil, err
}
return &KPSearchResponse{
PagesCount: response.PagesCount,
Films: response.Films,
SearchFilmsCountResult: len(response.Films),
}, nil
}
func KPIdToImdbId(kpService *KinopoiskService, kpId int) (string, error) {
film, err := kpService.GetFilmByKinopoiskId(kpId)
if err != nil {
return "", err
}
return film.ImdbId, nil
}
func ImdbIdToKPId(kpService *KinopoiskService, imdbId string) (int, error) {
film, err := kpService.GetFilmByImdbId(imdbId)
if err != nil {
return 0, err
}
return film.KinopoiskId, nil
}
func TmdbIdToKPId(tmdbService *TMDBService, kpService *KinopoiskService, tmdbId int) (int, error) {
externalIds, err := tmdbService.GetMovieExternalIDs(tmdbId)
if err != nil {
return 0, err
}
if externalIds.IMDbID == "" {
return 0, fmt.Errorf("no IMDb ID found for TMDB ID %d", tmdbId)
}
return ImdbIdToKPId(kpService, externalIds.IMDbID)
}
func KPIdToTmdbId(tmdbService *TMDBService, kpService *KinopoiskService, kpId int) (int, error) {
imdbId, err := KPIdToImdbId(kpService, kpId)
if err != nil {
return 0, err
}
movies, err := tmdbService.SearchMovies("", 1, "en-US", "", 0)
if err != nil {
return 0, err
}
for _, movie := range movies.Results {
ids, err := tmdbService.GetMovieExternalIDs(movie.ID)
if err != nil {
continue
}
if ids.IMDbID == imdbId {
return movie.ID, nil
}
}
return 0, fmt.Errorf("TMDB ID not found for KP ID %d", kpId)
}
func ConvertKPRating(rating float64) float64 {
return rating
}
func FormatKPYear(year int) string {
return strconv.Itoa(year)
}
package services
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
type KinopoiskService struct {
apiKey string
baseURL string
client *http.Client
}
type KPFilm struct {
KinopoiskId int `json:"kinopoiskId"`
ImdbId string `json:"imdbId"`
NameRu string `json:"nameRu"`
NameEn string `json:"nameEn"`
NameOriginal string `json:"nameOriginal"`
PosterUrl string `json:"posterUrl"`
PosterUrlPreview string `json:"posterUrlPreview"`
CoverUrl string `json:"coverUrl"`
LogoUrl string `json:"logoUrl"`
ReviewsCount int `json:"reviewsCount"`
RatingGoodReview float64 `json:"ratingGoodReview"`
RatingGoodReviewVoteCount int `json:"ratingGoodReviewVoteCount"`
RatingKinopoisk float64 `json:"ratingKinopoisk"`
RatingKinopoiskVoteCount int `json:"ratingKinopoiskVoteCount"`
RatingImdb float64 `json:"ratingImdb"`
RatingImdbVoteCount int `json:"ratingImdbVoteCount"`
RatingFilmCritics float64 `json:"ratingFilmCritics"`
RatingFilmCriticsVoteCount int `json:"ratingFilmCriticsVoteCount"`
RatingAwait float64 `json:"ratingAwait"`
RatingAwaitCount int `json:"ratingAwaitCount"`
RatingRfCritics float64 `json:"ratingRfCritics"`
RatingRfCriticsVoteCount int `json:"ratingRfCriticsVoteCount"`
WebUrl string `json:"webUrl"`
Year int `json:"year"`
FilmLength int `json:"filmLength"`
Slogan string `json:"slogan"`
Description string `json:"description"`
ShortDescription string `json:"shortDescription"`
EditorAnnotation string `json:"editorAnnotation"`
IsTicketsAvailable bool `json:"isTicketsAvailable"`
ProductionStatus string `json:"productionStatus"`
Type string `json:"type"`
RatingMpaa string `json:"ratingMpaa"`
RatingAgeLimits string `json:"ratingAgeLimits"`
HasImax bool `json:"hasImax"`
Has3D bool `json:"has3d"`
LastSync string `json:"lastSync"`
Countries []struct {
Country string `json:"country"`
} `json:"countries"`
Genres []struct {
Genre string `json:"genre"`
} `json:"genres"`
StartYear int `json:"startYear"`
EndYear int `json:"endYear"`
Serial bool `json:"serial"`
ShortFilm bool `json:"shortFilm"`
Completed bool `json:"completed"`
}
type KPSearchResponse struct {
Keyword string `json:"keyword"`
PagesCount int `json:"pagesCount"`
Films []KPFilmShort `json:"films"`
SearchFilmsCountResult int `json:"searchFilmsCountResult"`
}
type KPFilmShort struct {
FilmId int `json:"filmId"`
NameRu string `json:"nameRu"`
NameEn string `json:"nameEn"`
Type string `json:"type"`
Year string `json:"year"`
Description string `json:"description"`
FilmLength string `json:"filmLength"`
Countries []KPCountry `json:"countries"`
Genres []KPGenre `json:"genres"`
Rating string `json:"rating"`
RatingVoteCount int `json:"ratingVoteCount"`
PosterUrl string `json:"posterUrl"`
PosterUrlPreview string `json:"posterUrlPreview"`
}
type KPCountry struct {
Country string `json:"country"`
}
type KPGenre struct {
Genre string `json:"genre"`
}
type KPExternalSource struct {
Source string `json:"source"`
ID string `json:"id"`
}
func NewKinopoiskService(apiKey, baseURL string) *KinopoiskService {
return &KinopoiskService{
apiKey: apiKey,
baseURL: baseURL,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *KinopoiskService) makeRequest(endpoint string, target interface{}) error {
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return err
}
req.Header.Set("X-API-KEY", s.apiKey)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Kinopoisk API error: %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(target)
}
func (s *KinopoiskService) GetFilmByKinopoiskId(id int) (*KPFilm, error) {
endpoint := fmt.Sprintf("%s/v2.2/films/%d", s.baseURL, id)
var film KPFilm
err := s.makeRequest(endpoint, &film)
return &film, err
}
func (s *KinopoiskService) GetFilmByImdbId(imdbId string) (*KPFilm, error) {
endpoint := fmt.Sprintf("%s/v2.2/films?imdbId=%s", s.baseURL, url.QueryEscape(imdbId))
var response struct {
Films []KPFilm `json:"items"`
}
err := s.makeRequest(endpoint, &response)
if err != nil {
return nil, err
}
if len(response.Films) == 0 {
return nil, fmt.Errorf("film not found")
}
return &response.Films[0], nil
}
func (s *KinopoiskService) SearchFilms(keyword string, page int) (*KPSearchResponse, error) {
endpoint := fmt.Sprintf("%s/v2.1/films/search-by-keyword?keyword=%s&page=%d", s.baseURL, url.QueryEscape(keyword), page)
var response KPSearchResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *KinopoiskService) GetExternalSources(kinopoiskId int) ([]KPExternalSource, error) {
endpoint := fmt.Sprintf("%s/v2.2/films/%d/external_sources", s.baseURL, kinopoiskId)
var response struct {
Items []KPExternalSource `json:"items"`
}
err := s.makeRequest(endpoint, &response)
if err != nil {
return nil, err
}
return response.Items, nil
}
func (s *KinopoiskService) GetTopFilms(topType string, page int) (*KPSearchResponse, error) {
endpoint := fmt.Sprintf("%s/v2.2/films/top?type=%s&page=%d", s.baseURL, topType, page)
var response struct {
PagesCount int `json:"pagesCount"`
Films []KPFilmShort `json:"films"`
}
err := s.makeRequest(endpoint, &response)
if err != nil {
return nil, err
}
return &KPSearchResponse{
PagesCount: response.PagesCount,
Films: response.Films,
SearchFilmsCountResult: len(response.Films),
}, nil
}
func KPIdToImdbId(kpService *KinopoiskService, kpId int) (string, error) {
film, err := kpService.GetFilmByKinopoiskId(kpId)
if err != nil {
return "", err
}
return film.ImdbId, nil
}
func ImdbIdToKPId(kpService *KinopoiskService, imdbId string) (int, error) {
film, err := kpService.GetFilmByImdbId(imdbId)
if err != nil {
return 0, err
}
return film.KinopoiskId, nil
}
func TmdbIdToKPId(tmdbService *TMDBService, kpService *KinopoiskService, tmdbId int) (int, error) {
externalIds, err := tmdbService.GetMovieExternalIDs(tmdbId)
if err != nil {
return 0, err
}
if externalIds.IMDbID == "" {
return 0, fmt.Errorf("no IMDb ID found for TMDB ID %d", tmdbId)
}
return ImdbIdToKPId(kpService, externalIds.IMDbID)
}
func KPIdToTmdbId(tmdbService *TMDBService, kpService *KinopoiskService, kpId int) (int, error) {
imdbId, err := KPIdToImdbId(kpService, kpId)
if err != nil {
return 0, err
}
movies, err := tmdbService.SearchMovies("", 1, "en-US", "", 0)
if err != nil {
return 0, err
}
for _, movie := range movies.Results {
ids, err := tmdbService.GetMovieExternalIDs(movie.ID)
if err != nil {
continue
}
if ids.IMDbID == imdbId {
return movie.ID, nil
}
}
return 0, fmt.Errorf("TMDB ID not found for KP ID %d", kpId)
}
func ConvertKPRating(rating float64) float64 {
return rating
}
func FormatKPYear(year int) string {
return strconv.Itoa(year)
}

View File

@@ -1,424 +1,425 @@
package services
import (
"fmt"
"strconv"
"strings"
"time"
"neomovies-api/pkg/models"
)
func MapKPFilmToTMDBMovie(kpFilm *KPFilm) *models.Movie {
if kpFilm == nil {
return nil
}
releaseDate := ""
if kpFilm.Year > 0 {
releaseDate = fmt.Sprintf("%d-01-01", kpFilm.Year)
}
genres := make([]models.Genre, 0)
for _, g := range kpFilm.Genres {
genres = append(genres, models.Genre{
ID: 0,
Name: g.Genre,
})
}
countries := make([]models.ProductionCountry, 0)
for _, c := range kpFilm.Countries {
countries = append(countries, models.ProductionCountry{
ISO31661: "",
Name: c.Country,
})
}
posterPath := ""
if kpFilm.PosterUrlPreview != "" {
posterPath = kpFilm.PosterUrlPreview
} else if kpFilm.PosterUrl != "" {
posterPath = kpFilm.PosterUrl
}
backdropPath := ""
if kpFilm.CoverUrl != "" {
backdropPath = kpFilm.CoverUrl
}
overview := kpFilm.Description
if overview == "" {
overview = kpFilm.ShortDescription
}
title := kpFilm.NameRu
if title == "" {
title = kpFilm.NameEn
}
if title == "" {
title = kpFilm.NameOriginal
}
originalTitle := kpFilm.NameOriginal
if originalTitle == "" {
originalTitle = kpFilm.NameEn
}
return &models.Movie{
ID: kpFilm.KinopoiskId,
Title: title,
OriginalTitle: originalTitle,
Overview: overview,
PosterPath: posterPath,
BackdropPath: backdropPath,
ReleaseDate: releaseDate,
VoteAverage: kpFilm.RatingKinopoisk,
VoteCount: kpFilm.RatingKinopoiskVoteCount,
Popularity: float64(kpFilm.RatingKinopoisk * 100),
Adult: false,
OriginalLanguage: detectLanguage(kpFilm),
Runtime: kpFilm.FilmLength,
Genres: genres,
Tagline: kpFilm.Slogan,
ProductionCountries: countries,
IMDbID: kpFilm.ImdbId,
KinopoiskID: kpFilm.KinopoiskId,
}
}
func MapKPFilmToTVShow(kpFilm *KPFilm) *models.TVShow {
if kpFilm == nil {
return nil
}
firstAirDate := ""
if kpFilm.StartYear > 0 {
firstAirDate = fmt.Sprintf("%d-01-01", kpFilm.StartYear)
}
lastAirDate := ""
if kpFilm.EndYear > 0 {
lastAirDate = fmt.Sprintf("%d-01-01", kpFilm.EndYear)
}
genres := make([]models.Genre, 0)
for _, g := range kpFilm.Genres {
genres = append(genres, models.Genre{
ID: 0,
Name: g.Genre,
})
}
posterPath := ""
if kpFilm.PosterUrlPreview != "" {
posterPath = kpFilm.PosterUrlPreview
} else if kpFilm.PosterUrl != "" {
posterPath = kpFilm.PosterUrl
}
backdropPath := ""
if kpFilm.CoverUrl != "" {
backdropPath = kpFilm.CoverUrl
}
overview := kpFilm.Description
if overview == "" {
overview = kpFilm.ShortDescription
}
name := kpFilm.NameRu
if name == "" {
name = kpFilm.NameEn
}
if name == "" {
name = kpFilm.NameOriginal
}
originalName := kpFilm.NameOriginal
if originalName == "" {
originalName = kpFilm.NameEn
}
status := "Ended"
if kpFilm.Completed {
status = "Ended"
} else {
status = "Returning Series"
}
return &models.TVShow{
ID: kpFilm.KinopoiskId,
Name: name,
OriginalName: originalName,
Overview: overview,
PosterPath: posterPath,
BackdropPath: backdropPath,
FirstAirDate: firstAirDate,
LastAirDate: lastAirDate,
VoteAverage: kpFilm.RatingKinopoisk,
VoteCount: kpFilm.RatingKinopoiskVoteCount,
Popularity: float64(kpFilm.RatingKinopoisk * 100),
OriginalLanguage: detectLanguage(kpFilm),
Genres: genres,
Status: status,
InProduction: !kpFilm.Completed,
KinopoiskID: kpFilm.KinopoiskId,
}
}
// Unified mappers with prefixed IDs
func MapKPToUnified(kpFilm *KPFilm) *models.UnifiedContent {
if kpFilm == nil {
return nil
}
releaseDate := FormatKPDate(kpFilm.Year)
endDate := (*string)(nil)
if kpFilm.EndYear > 0 {
v := FormatKPDate(kpFilm.EndYear)
endDate = &v
}
genres := make([]models.UnifiedGenre, 0)
for _, g := range kpFilm.Genres {
genres = append(genres, models.UnifiedGenre{ID: strings.ToLower(g.Genre), Name: g.Genre})
}
poster := kpFilm.PosterUrlPreview
if poster == "" {
poster = kpFilm.PosterUrl
}
country := ""
if len(kpFilm.Countries) > 0 {
country = kpFilm.Countries[0].Country
}
title := kpFilm.NameRu
if title == "" {
title = kpFilm.NameEn
}
originalTitle := kpFilm.NameOriginal
if originalTitle == "" {
originalTitle = kpFilm.NameEn
}
var budgetPtr *int64
var revenuePtr *int64
external := models.UnifiedExternalIDs{KP: &kpFilm.KinopoiskId, TMDB: nil, IMDb: kpFilm.ImdbId}
return &models.UnifiedContent{
ID: strconv.Itoa(kpFilm.KinopoiskId),
SourceID: "kp_" + strconv.Itoa(kpFilm.KinopoiskId),
Title: title,
OriginalTitle: originalTitle,
Description: firstNonEmpty(kpFilm.Description, kpFilm.ShortDescription),
ReleaseDate: releaseDate,
EndDate: endDate,
Type: mapKPTypeToUnified(kpFilm),
Genres: genres,
Rating: kpFilm.RatingKinopoisk,
PosterURL: BuildAPIImageProxyURL(poster, "w300"),
BackdropURL: BuildAPIImageProxyURL(kpFilm.CoverUrl, "w1280"),
Director: "",
Cast: []models.UnifiedCastMember{},
Duration: kpFilm.FilmLength,
Country: country,
Language: detectLanguage(kpFilm),
Budget: budgetPtr,
Revenue: revenuePtr,
IMDbID: kpFilm.ImdbId,
ExternalIDs: external,
}
}
func mapKPTypeToUnified(kp *KPFilm) string {
if kp.Serial || kp.Type == "TV_SERIES" || kp.Type == "MINI_SERIES" {
return "tv"
}
return "movie"
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func MapKPSearchToTMDBResponse(kpSearch *KPSearchResponse) *models.TMDBResponse {
if kpSearch == nil {
return &models.TMDBResponse{
Page: 1,
Results: []models.Movie{},
TotalPages: 0,
TotalResults: 0,
}
}
results := make([]models.Movie, 0)
for _, film := range kpSearch.Films {
movie := mapKPFilmShortToMovie(film)
if movie != nil {
results = append(results, *movie)
}
}
totalPages := kpSearch.PagesCount
if totalPages == 0 && len(results) > 0 {
totalPages = 1
}
return &models.TMDBResponse{
Page: 1,
Results: results,
TotalPages: totalPages,
TotalResults: kpSearch.SearchFilmsCountResult,
}
}
func mapKPFilmShortToMovie(film KPFilmShort) *models.Movie {
genres := make([]models.Genre, 0)
for _, g := range film.Genres {
genres = append(genres, models.Genre{
ID: 0,
Name: g.Genre,
})
}
year := 0
if film.Year != "" {
year, _ = strconv.Atoi(film.Year)
}
releaseDate := ""
if year > 0 {
releaseDate = fmt.Sprintf("%d-01-01", year)
}
posterPath := film.PosterUrlPreview
if posterPath == "" {
posterPath = film.PosterUrl
}
title := film.NameRu
if title == "" {
title = film.NameEn
}
originalTitle := film.NameEn
if originalTitle == "" {
originalTitle = film.NameRu
}
rating := 0.0
if film.Rating != "" {
rating, _ = strconv.ParseFloat(film.Rating, 64)
}
return &models.Movie{
ID: film.FilmId,
Title: title,
OriginalTitle: originalTitle,
Overview: film.Description,
PosterPath: posterPath,
ReleaseDate: releaseDate,
VoteAverage: rating,
VoteCount: film.RatingVoteCount,
Popularity: rating * 100,
Genres: genres,
KinopoiskID: film.FilmId,
}
}
func detectLanguage(film *KPFilm) string {
if film.NameRu != "" {
return "ru"
}
if film.NameEn != "" {
return "en"
}
return "ru"
}
func MapKPExternalIDsToTMDB(kpFilm *KPFilm) *models.ExternalIDs {
if kpFilm == nil {
return &models.ExternalIDs{}
}
return &models.ExternalIDs{
ID: kpFilm.KinopoiskId,
IMDbID: kpFilm.ImdbId,
KinopoiskID: kpFilm.KinopoiskId,
}
}
func ShouldUseKinopoisk(language string) bool {
if language == "" {
return false
}
lang := strings.ToLower(language)
return strings.HasPrefix(lang, "ru")
}
func NormalizeLanguage(language string) string {
if language == "" {
return "en-US"
}
lang := strings.ToLower(language)
if strings.HasPrefix(lang, "ru") {
return "ru-RU"
}
return "en-US"
}
func ConvertKPRatingToTMDB(kpRating float64) float64 {
return kpRating
}
func FormatKPDate(year int) string {
if year <= 0 {
return time.Now().Format("2006-01-02")
}
return fmt.Sprintf("%d-01-01", year)
}
// EnrichKPWithTMDBID обогащает KP контент TMDB ID через IMDB ID
func EnrichKPWithTMDBID(content *models.UnifiedContent, tmdbService *TMDBService) {
if content == nil || content.IMDbID == "" || content.ExternalIDs.TMDB != nil {
return
}
mediaType := "movie"
if content.Type == "tv" {
mediaType = "tv"
}
if tmdbID, err := tmdbService.FindTMDBIdByIMDB(content.IMDbID, mediaType, "ru-RU"); err == nil {
content.ExternalIDs.TMDB = &tmdbID
}
}
// EnrichKPSearchItemsWithTMDBID обогащает массив поисковых элементов TMDB ID
func EnrichKPSearchItemsWithTMDBID(items []models.UnifiedSearchItem, tmdbService *TMDBService) {
for i := range items {
if items[i].ExternalIDs.IMDb == "" || items[i].ExternalIDs.TMDB != nil {
continue
}
mediaType := "movie"
if items[i].Type == "tv" {
mediaType = "tv"
}
if tmdbID, err := tmdbService.FindTMDBIdByIMDB(items[i].ExternalIDs.IMDb, mediaType, "ru-RU"); err == nil {
items[i].ExternalIDs.TMDB = &tmdbID
}
}
}
package services
import (
"fmt"
"strconv"
"strings"
"time"
"neomovies-api/pkg/models"
)
func MapKPFilmToTMDBMovie(kpFilm *KPFilm) *models.Movie {
if kpFilm == nil {
return nil
}
releaseDate := ""
if kpFilm.Year > 0 {
releaseDate = fmt.Sprintf("%d-01-01", kpFilm.Year)
}
genres := make([]models.Genre, 0)
for _, g := range kpFilm.Genres {
genres = append(genres, models.Genre{
ID: 0,
Name: g.Genre,
})
}
countries := make([]models.ProductionCountry, 0)
for _, c := range kpFilm.Countries {
countries = append(countries, models.ProductionCountry{
ISO31661: "",
Name: c.Country,
})
}
posterPath := ""
if kpFilm.PosterUrlPreview != "" {
posterPath = kpFilm.PosterUrlPreview
} else if kpFilm.PosterUrl != "" {
posterPath = kpFilm.PosterUrl
}
backdropPath := ""
if kpFilm.CoverUrl != "" {
backdropPath = kpFilm.CoverUrl
}
overview := kpFilm.Description
if overview == "" {
overview = kpFilm.ShortDescription
}
title := kpFilm.NameRu
if title == "" {
title = kpFilm.NameEn
}
if title == "" {
title = kpFilm.NameOriginal
}
originalTitle := kpFilm.NameOriginal
if originalTitle == "" {
originalTitle = kpFilm.NameEn
}
return &models.Movie{
ID: kpFilm.KinopoiskId,
Title: title,
OriginalTitle: originalTitle,
Overview: overview,
PosterPath: posterPath,
BackdropPath: backdropPath,
ReleaseDate: releaseDate,
VoteAverage: kpFilm.RatingKinopoisk,
VoteCount: kpFilm.RatingKinopoiskVoteCount,
Popularity: float64(kpFilm.RatingKinopoisk * 100),
Adult: false,
OriginalLanguage: detectLanguage(kpFilm),
Runtime: kpFilm.FilmLength,
Genres: genres,
Tagline: kpFilm.Slogan,
ProductionCountries: countries,
IMDbID: kpFilm.ImdbId,
KinopoiskID: kpFilm.KinopoiskId,
}
}
func MapKPFilmToTVShow(kpFilm *KPFilm) *models.TVShow {
if kpFilm == nil {
return nil
}
firstAirDate := ""
if kpFilm.StartYear > 0 {
firstAirDate = fmt.Sprintf("%d-01-01", kpFilm.StartYear)
}
lastAirDate := ""
if kpFilm.EndYear > 0 {
lastAirDate = fmt.Sprintf("%d-01-01", kpFilm.EndYear)
}
genres := make([]models.Genre, 0)
for _, g := range kpFilm.Genres {
genres = append(genres, models.Genre{
ID: 0,
Name: g.Genre,
})
}
posterPath := ""
if kpFilm.PosterUrlPreview != "" {
posterPath = kpFilm.PosterUrlPreview
} else if kpFilm.PosterUrl != "" {
posterPath = kpFilm.PosterUrl
}
backdropPath := ""
if kpFilm.CoverUrl != "" {
backdropPath = kpFilm.CoverUrl
}
overview := kpFilm.Description
if overview == "" {
overview = kpFilm.ShortDescription
}
name := kpFilm.NameRu
if name == "" {
name = kpFilm.NameEn
}
if name == "" {
name = kpFilm.NameOriginal
}
originalName := kpFilm.NameOriginal
if originalName == "" {
originalName = kpFilm.NameEn
}
status := "Ended"
if kpFilm.Completed {
status = "Ended"
} else {
status = "Returning Series"
}
return &models.TVShow{
ID: kpFilm.KinopoiskId,
Name: name,
OriginalName: originalName,
Overview: overview,
PosterPath: posterPath,
BackdropPath: backdropPath,
FirstAirDate: firstAirDate,
LastAirDate: lastAirDate,
VoteAverage: kpFilm.RatingKinopoisk,
VoteCount: kpFilm.RatingKinopoiskVoteCount,
Popularity: float64(kpFilm.RatingKinopoisk * 100),
OriginalLanguage: detectLanguage(kpFilm),
Genres: genres,
Status: status,
InProduction: !kpFilm.Completed,
KinopoiskID: kpFilm.KinopoiskId,
}
}
// Unified mappers with prefixed IDs
func MapKPToUnified(kpFilm *KPFilm) *models.UnifiedContent {
if kpFilm == nil {
return nil
}
releaseDate := FormatKPDate(kpFilm.Year)
endDate := (*string)(nil)
if kpFilm.EndYear > 0 {
v := FormatKPDate(kpFilm.EndYear)
endDate = &v
}
genres := make([]models.UnifiedGenre, 0)
for _, g := range kpFilm.Genres {
genres = append(genres, models.UnifiedGenre{ID: strings.ToLower(g.Genre), Name: g.Genre})
}
poster := kpFilm.PosterUrlPreview
if poster == "" {
poster = kpFilm.PosterUrl
}
country := ""
if len(kpFilm.Countries) > 0 {
country = kpFilm.Countries[0].Country
}
title := kpFilm.NameRu
if title == "" {
title = kpFilm.NameEn
}
originalTitle := kpFilm.NameOriginal
if originalTitle == "" {
originalTitle = kpFilm.NameEn
}
var budgetPtr *int64
var revenuePtr *int64
external := models.UnifiedExternalIDs{KP: &kpFilm.KinopoiskId, TMDB: nil, IMDb: kpFilm.ImdbId}
return &models.UnifiedContent{
ID: strconv.Itoa(kpFilm.KinopoiskId),
SourceID: "kp_" + strconv.Itoa(kpFilm.KinopoiskId),
Title: title,
OriginalTitle: originalTitle,
Description: firstNonEmpty(kpFilm.Description, kpFilm.ShortDescription),
ReleaseDate: releaseDate,
EndDate: endDate,
Type: mapKPTypeToUnified(kpFilm),
Genres: genres,
Rating: kpFilm.RatingKinopoisk,
PosterURL: BuildAPIImageProxyURL(poster, "w300"),
BackdropURL: BuildAPIImageProxyURL(kpFilm.CoverUrl, "w1280"),
Director: "",
Cast: []models.UnifiedCastMember{},
Duration: kpFilm.FilmLength,
Country: country,
Language: detectLanguage(kpFilm),
Budget: budgetPtr,
Revenue: revenuePtr,
IMDbID: kpFilm.ImdbId,
ExternalIDs: external,
}
}
func mapKPTypeToUnified(kp *KPFilm) string {
if kp.Serial || kp.Type == "TV_SERIES" || kp.Type == "MINI_SERIES" {
return "tv"
}
return "movie"
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func MapKPSearchToTMDBResponse(kpSearch *KPSearchResponse) *models.TMDBResponse {
if kpSearch == nil {
return &models.TMDBResponse{
Page: 1,
Results: []models.Movie{},
TotalPages: 0,
TotalResults: 0,
}
}
results := make([]models.Movie, 0)
for _, film := range kpSearch.Films {
movie := mapKPFilmShortToMovie(film)
if movie != nil {
results = append(results, *movie)
}
}
totalPages := kpSearch.PagesCount
if totalPages == 0 && len(results) > 0 {
totalPages = 1
}
return &models.TMDBResponse{
Page: 1,
Results: results,
TotalPages: totalPages,
TotalResults: kpSearch.SearchFilmsCountResult,
}
}
func mapKPFilmShortToMovie(film KPFilmShort) *models.Movie {
genres := make([]models.Genre, 0)
for _, g := range film.Genres {
genres = append(genres, models.Genre{
ID: 0,
Name: g.Genre,
})
}
year := 0
if film.Year != "" {
year, _ = strconv.Atoi(film.Year)
}
releaseDate := ""
if year > 0 {
releaseDate = fmt.Sprintf("%d-01-01", year)
}
// Приоритет: PosterUrlPreview > PosterUrl
posterPath := film.PosterUrlPreview
if posterPath == "" {
posterPath = film.PosterUrl
}
title := film.NameRu
if title == "" {
title = film.NameEn
}
originalTitle := film.NameEn
if originalTitle == "" {
originalTitle = film.NameRu
}
rating := 0.0
if film.Rating != "" {
rating, _ = strconv.ParseFloat(film.Rating, 64)
}
return &models.Movie{
ID: film.FilmId,
Title: title,
OriginalTitle: originalTitle,
Overview: film.Description,
PosterPath: posterPath,
ReleaseDate: releaseDate,
VoteAverage: rating,
VoteCount: film.RatingVoteCount,
Popularity: rating * 100,
Genres: genres,
KinopoiskID: film.FilmId,
}
}
func detectLanguage(film *KPFilm) string {
if film.NameRu != "" {
return "ru"
}
if film.NameEn != "" {
return "en"
}
return "ru"
}
func MapKPExternalIDsToTMDB(kpFilm *KPFilm) *models.ExternalIDs {
if kpFilm == nil {
return &models.ExternalIDs{}
}
return &models.ExternalIDs{
ID: kpFilm.KinopoiskId,
IMDbID: kpFilm.ImdbId,
KinopoiskID: kpFilm.KinopoiskId,
}
}
func ShouldUseKinopoisk(language string) bool {
if language == "" {
return false
}
lang := strings.ToLower(language)
return strings.HasPrefix(lang, "ru")
}
func NormalizeLanguage(language string) string {
if language == "" {
return "en-US"
}
lang := strings.ToLower(language)
if strings.HasPrefix(lang, "ru") {
return "ru-RU"
}
return "en-US"
}
func ConvertKPRatingToTMDB(kpRating float64) float64 {
return kpRating
}
func FormatKPDate(year int) string {
if year <= 0 {
return time.Now().Format("2006-01-02")
}
return fmt.Sprintf("%d-01-01", year)
}
// EnrichKPWithTMDBID обогащает KP контент TMDB ID через IMDB ID
func EnrichKPWithTMDBID(content *models.UnifiedContent, tmdbService *TMDBService) {
if content == nil || content.IMDbID == "" || content.ExternalIDs.TMDB != nil {
return
}
mediaType := "movie"
if content.Type == "tv" {
mediaType = "tv"
}
if tmdbID, err := tmdbService.FindTMDBIdByIMDB(content.IMDbID, mediaType, "ru-RU"); err == nil {
content.ExternalIDs.TMDB = &tmdbID
}
}
// EnrichKPSearchItemsWithTMDBID обогащает массив поисковых элементов TMDB ID
func EnrichKPSearchItemsWithTMDBID(items []models.UnifiedSearchItem, tmdbService *TMDBService) {
for i := range items {
if items[i].ExternalIDs.IMDb == "" || items[i].ExternalIDs.TMDB != nil {
continue
}
mediaType := "movie"
if items[i].Type == "tv" {
mediaType = "tv"
}
if tmdbID, err := tmdbService.FindTMDBIdByIMDB(items[i].ExternalIDs.IMDb, mediaType, "ru-RU"); err == nil {
items[i].ExternalIDs.TMDB = &tmdbID
}
}
}

View File

@@ -2,6 +2,7 @@ package services
import (
"fmt"
"log"
"go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models"
@@ -67,22 +68,42 @@ func (s *MovieService) GetByID(id int, language string, idType string) (*models.
}
func (s *MovieService) GetPopular(page int, language, region string) (*models.TMDBResponse, error) {
log.Printf("[GetPopular] language=%s, region=%s, page=%d", language, region, page)
if ShouldUseKinopoisk(language) && s.kpService != nil {
log.Printf("[GetPopular] Using Kinopoisk for language: %s", language)
kpTop, err := s.kpService.GetTopFilms("TOP_100_POPULAR_FILMS", page)
if err == nil {
if err != nil {
log.Printf("[GetPopular] Kinopoisk error: %v, falling back to TMDB", err)
} else if kpTop != nil && len(kpTop.Films) > 0 {
log.Printf("[GetPopular] Got %d films from Kinopoisk", len(kpTop.Films))
return MapKPSearchToTMDBResponse(kpTop), nil
} else {
log.Printf("[GetPopular] Kinopoisk returned empty results, falling back to TMDB")
}
}
log.Printf("[GetPopular] Using TMDB for language: %s", language)
return s.tmdb.GetPopularMovies(page, language, region)
}
func (s *MovieService) GetTopRated(page int, language, region string) (*models.TMDBResponse, error) {
log.Printf("[GetTopRated] language=%s, region=%s, page=%d", language, region, page)
if ShouldUseKinopoisk(language) && s.kpService != nil {
log.Printf("[GetTopRated] Using Kinopoisk for language: %s", language)
kpTop, err := s.kpService.GetTopFilms("TOP_250_BEST_FILMS", page)
if err == nil {
if err != nil {
log.Printf("[GetTopRated] Kinopoisk error: %v, falling back to TMDB", err)
} else if kpTop != nil && len(kpTop.Films) > 0 {
log.Printf("[GetTopRated] Got %d films from Kinopoisk", len(kpTop.Films))
return MapKPSearchToTMDBResponse(kpTop), nil
} else {
log.Printf("[GetTopRated] Kinopoisk returned empty results, falling back to TMDB")
}
}
log.Printf("[GetTopRated] Using TMDB for language: %s", language)
return s.tmdb.GetTopRatedMovies(page, language, region)
}

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,134 +1,134 @@
package services
import (
"fmt"
"go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models"
)
type TVService struct {
db *mongo.Database
tmdb *TMDBService
kpService *KinopoiskService
}
func NewTVService(db *mongo.Database, tmdb *TMDBService, kpService *KinopoiskService) *TVService {
return &TVService{
db: db,
tmdb: tmdb,
kpService: kpService,
}
}
func (s *TVService) Search(query string, page int, language string, year int) (*models.TMDBTVResponse, error) {
return s.tmdb.SearchTVShows(query, page, language, year)
}
func (s *TVService) GetByID(id int, language string, idType string) (*models.TVShow, error) {
// Строго уважаем явный id_type, без скрытого fallback на TMDB
switch idType {
case "kp":
if s.kpService == nil {
return nil, fmt.Errorf("kinopoisk service not configured")
}
// Сначала пробуем как Kinopoisk ID
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(id); err == nil && kpFilm != nil {
// Попробуем обогатить TMDB сериал через IMDb -> TMDB find
if kpFilm.ImdbId != "" {
if tmdbID, fErr := s.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, "tv", NormalizeLanguage(language)); fErr == nil {
if tmdbTV, mErr := s.tmdb.GetTVShow(tmdbID, NormalizeLanguage(language)); mErr == nil {
return tmdbTV, nil
}
}
}
return MapKPFilmToTVShow(kpFilm), nil
}
// Возможно пришел TMDB ID — пробуем конвертировать TMDB -> KP
if kpId, convErr := TmdbIdToKPId(s.tmdb, s.kpService, id); convErr == nil {
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(kpId); err == nil && kpFilm != nil {
if kpFilm.ImdbId != "" {
if tmdbID, fErr := s.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, "tv", NormalizeLanguage(language)); fErr == nil {
if tmdbTV, mErr := s.tmdb.GetTVShow(tmdbID, NormalizeLanguage(language)); mErr == nil {
return tmdbTV, nil
}
}
}
return MapKPFilmToTVShow(kpFilm), nil
}
}
// Явно указан KP, но ничего не нашли — возвращаем ошибку
return nil, fmt.Errorf("TV show not found in Kinopoisk with id %d", id)
case "tmdb":
return s.tmdb.GetTVShow(id, language)
}
// Если id_type не указан — старая логика по языку
if ShouldUseKinopoisk(language) && s.kpService != nil {
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(id); err == nil && kpFilm != nil {
return MapKPFilmToTVShow(kpFilm), nil
}
}
return s.tmdb.GetTVShow(id, language)
}
func (s *TVService) GetPopular(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetPopularTVShows(page, language)
}
func (s *TVService) GetTopRated(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetTopRatedTVShows(page, language)
}
func (s *TVService) GetOnTheAir(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetOnTheAirTVShows(page, language)
}
func (s *TVService) GetAiringToday(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetAiringTodayTVShows(page, language)
}
func (s *TVService) GetRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetTVRecommendations(id, page, language)
}
func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetSimilarTVShows(id, page, language)
}
func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
if s.kpService != nil {
kpFilm, err := s.kpService.GetFilmByKinopoiskId(id)
if err == nil && kpFilm != nil {
externalIDs := MapKPExternalIDsToTMDB(kpFilm)
externalIDs.ID = id
// Пытаемся получить TMDB ID через IMDB ID
if kpFilm.ImdbId != "" && s.tmdb != nil {
if tmdbID, tmdbErr := s.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, "tv", "ru-RU"); tmdbErr == nil {
externalIDs.TMDBID = tmdbID
}
}
return externalIDs, nil
}
}
tmdbIDs, err := s.tmdb.GetTVExternalIDs(id)
if err != nil {
return nil, err
}
if s.kpService != nil && tmdbIDs.IMDbID != "" {
kpFilm, err := s.kpService.GetFilmByImdbId(tmdbIDs.IMDbID)
if err == nil && kpFilm != nil {
tmdbIDs.KinopoiskID = kpFilm.KinopoiskId
}
}
return tmdbIDs, nil
}
package services
import (
"fmt"
"go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models"
)
type TVService struct {
db *mongo.Database
tmdb *TMDBService
kpService *KinopoiskService
}
func NewTVService(db *mongo.Database, tmdb *TMDBService, kpService *KinopoiskService) *TVService {
return &TVService{
db: db,
tmdb: tmdb,
kpService: kpService,
}
}
func (s *TVService) Search(query string, page int, language string, year int) (*models.TMDBTVResponse, error) {
return s.tmdb.SearchTVShows(query, page, language, year)
}
func (s *TVService) GetByID(id int, language string, idType string) (*models.TVShow, error) {
// Строго уважаем явный id_type, без скрытого fallback на TMDB
switch idType {
case "kp":
if s.kpService == nil {
return nil, fmt.Errorf("kinopoisk service not configured")
}
// Сначала пробуем как Kinopoisk ID
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(id); err == nil && kpFilm != nil {
// Попробуем обогатить TMDB сериал через IMDb -> TMDB find
if kpFilm.ImdbId != "" {
if tmdbID, fErr := s.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, "tv", NormalizeLanguage(language)); fErr == nil {
if tmdbTV, mErr := s.tmdb.GetTVShow(tmdbID, NormalizeLanguage(language)); mErr == nil {
return tmdbTV, nil
}
}
}
return MapKPFilmToTVShow(kpFilm), nil
}
// Возможно пришел TMDB ID — пробуем конвертировать TMDB -> KP
if kpId, convErr := TmdbIdToKPId(s.tmdb, s.kpService, id); convErr == nil {
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(kpId); err == nil && kpFilm != nil {
if kpFilm.ImdbId != "" {
if tmdbID, fErr := s.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, "tv", NormalizeLanguage(language)); fErr == nil {
if tmdbTV, mErr := s.tmdb.GetTVShow(tmdbID, NormalizeLanguage(language)); mErr == nil {
return tmdbTV, nil
}
}
}
return MapKPFilmToTVShow(kpFilm), nil
}
}
// Явно указан KP, но ничего не нашли — возвращаем ошибку
return nil, fmt.Errorf("TV show not found in Kinopoisk with id %d", id)
case "tmdb":
return s.tmdb.GetTVShow(id, language)
}
// Если id_type не указан — старая логика по языку
if ShouldUseKinopoisk(language) && s.kpService != nil {
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(id); err == nil && kpFilm != nil {
return MapKPFilmToTVShow(kpFilm), nil
}
}
return s.tmdb.GetTVShow(id, language)
}
func (s *TVService) GetPopular(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetPopularTVShows(page, language)
}
func (s *TVService) GetTopRated(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetTopRatedTVShows(page, language)
}
func (s *TVService) GetOnTheAir(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetOnTheAirTVShows(page, language)
}
func (s *TVService) GetAiringToday(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetAiringTodayTVShows(page, language)
}
func (s *TVService) GetRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetTVRecommendations(id, page, language)
}
func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetSimilarTVShows(id, page, language)
}
func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
if s.kpService != nil {
kpFilm, err := s.kpService.GetFilmByKinopoiskId(id)
if err == nil && kpFilm != nil {
externalIDs := MapKPExternalIDsToTMDB(kpFilm)
externalIDs.ID = id
// Пытаемся получить TMDB ID через IMDB ID
if kpFilm.ImdbId != "" && s.tmdb != nil {
if tmdbID, tmdbErr := s.tmdb.FindTMDBIdByIMDB(kpFilm.ImdbId, "tv", "ru-RU"); tmdbErr == nil {
externalIDs.TMDBID = tmdbID
}
}
return externalIDs, nil
}
}
tmdbIDs, err := s.tmdb.GetTVExternalIDs(id)
if err != nil {
return nil, err
}
if s.kpService != nil && tmdbIDs.IMDbID != "" {
kpFilm, err := s.kpService.GetFilmByImdbId(tmdbIDs.IMDbID)
if err == nil && kpFilm != nil {
tmdbIDs.KinopoiskID = kpFilm.KinopoiskId
}
}
return tmdbIDs, nil
}

View File

@@ -1,291 +1,291 @@
package services
import (
"fmt"
"strconv"
"strings"
"neomovies-api/pkg/models"
)
const tmdbImageBase = "https://image.tmdb.org/t/p"
func BuildAPIImageProxyURL(pathOrURL string, size string) string {
if strings.TrimSpace(pathOrURL) == "" {
return ""
}
// Extract type and ID from Kinopoisk URL pattern
// https://kinopoiskapiunofficial.tech/images/posters/{type}/{id}.jpg
if strings.Contains(pathOrURL, "kinopoiskapiunofficial.tech") {
parts := strings.Split(pathOrURL, "/")
if len(parts) >= 2 {
// Find "posters" index
for i, part := range parts {
if part == "posters" && i+2 < len(parts) {
imageType := parts[i+1] // kp, kp_small, kp_big
idWithExt := parts[i+2] // 326.jpg
imageID := strings.TrimSuffix(idWithExt, ".jpg")
// Map size to type if needed
if size == "w1280" || size == "original" {
imageType = "kp_big"
} else if size == "w300" || size == "w185" {
imageType = "kp_small"
}
return fmt.Sprintf("/api/v1/images/%s/%s", imageType, imageID)
}
}
}
}
// Yandex/other absolute URLs - return empty for now
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
return ""
}
// TMDB relative path - not supported in new format
return ""
}
func MapTMDBToUnifiedMovie(movie *models.Movie, external *models.ExternalIDs) *models.UnifiedContent {
if movie == nil {
return nil
}
genres := make([]models.UnifiedGenre, 0, len(movie.Genres))
for _, g := range movie.Genres {
name := strings.TrimSpace(g.Name)
id := strings.ToLower(strings.ReplaceAll(name, " ", "-"))
if id == "" {
id = strconv.Itoa(g.ID)
}
genres = append(genres, models.UnifiedGenre{ID: id, Name: name})
}
var imdb string
if external != nil {
imdb = external.IMDbID
}
var budgetPtr *int64
if movie.Budget > 0 {
v := movie.Budget
budgetPtr = &v
}
var revenuePtr *int64
if movie.Revenue > 0 {
v := movie.Revenue
revenuePtr = &v
}
ext := models.UnifiedExternalIDs{
KP: nil,
TMDB: &movie.ID,
IMDb: imdb,
}
return &models.UnifiedContent{
ID: strconv.Itoa(movie.ID),
SourceID: "tmdb_" + strconv.Itoa(movie.ID),
Title: movie.Title,
OriginalTitle: movie.OriginalTitle,
Description: movie.Overview,
ReleaseDate: movie.ReleaseDate,
EndDate: nil,
Type: "movie",
Genres: genres,
Rating: movie.VoteAverage,
PosterURL: BuildAPIImageProxyURL(movie.PosterPath, "w300"),
BackdropURL: BuildAPIImageProxyURL(movie.BackdropPath, "w1280"),
Director: "",
Cast: []models.UnifiedCastMember{},
Duration: movie.Runtime,
Country: firstCountry(movie.ProductionCountries),
Language: movie.OriginalLanguage,
Budget: budgetPtr,
Revenue: revenuePtr,
IMDbID: imdb,
ExternalIDs: ext,
}
}
func MapTMDBTVToUnified(tv *models.TVShow, external *models.ExternalIDs) *models.UnifiedContent {
if tv == nil {
return nil
}
genres := make([]models.UnifiedGenre, 0, len(tv.Genres))
for _, g := range tv.Genres {
name := strings.TrimSpace(g.Name)
id := strings.ToLower(strings.ReplaceAll(name, " ", "-"))
if id == "" {
id = strconv.Itoa(g.ID)
}
genres = append(genres, models.UnifiedGenre{ID: id, Name: name})
}
var imdb string
if external != nil {
imdb = external.IMDbID
}
endDate := (*string)(nil)
if strings.TrimSpace(tv.LastAirDate) != "" {
v := tv.LastAirDate
endDate = &v
}
ext := models.UnifiedExternalIDs{
KP: nil,
TMDB: &tv.ID,
IMDb: imdb,
}
duration := 0
if len(tv.EpisodeRunTime) > 0 {
duration = tv.EpisodeRunTime[0]
}
unified := &models.UnifiedContent{
ID: strconv.Itoa(tv.ID),
SourceID: "tmdb_" + strconv.Itoa(tv.ID),
Title: tv.Name,
OriginalTitle: tv.OriginalName,
Description: tv.Overview,
ReleaseDate: tv.FirstAirDate,
EndDate: endDate,
Type: "tv",
Genres: genres,
Rating: tv.VoteAverage,
PosterURL: BuildAPIImageProxyURL(tv.PosterPath, "w300"),
BackdropURL: BuildAPIImageProxyURL(tv.BackdropPath, "w1280"),
Director: "",
Cast: []models.UnifiedCastMember{},
Duration: duration,
Country: firstCountry(tv.ProductionCountries),
Language: tv.OriginalLanguage,
Budget: nil,
Revenue: nil,
IMDbID: imdb,
ExternalIDs: ext,
}
// Map seasons basic info
if len(tv.Seasons) > 0 {
unified.Seasons = make([]models.UnifiedSeason, 0, len(tv.Seasons))
for _, s := range tv.Seasons {
unified.Seasons = append(unified.Seasons, models.UnifiedSeason{
ID: strconv.Itoa(s.ID),
SourceID: "tmdb_" + strconv.Itoa(s.ID),
Name: s.Name,
SeasonNumber: s.SeasonNumber,
EpisodeCount: s.EpisodeCount,
ReleaseDate: s.AirDate,
PosterURL: BuildAPIImageProxyURL(s.PosterPath, "w300"),
})
}
}
return unified
}
func MapTMDBMultiToUnifiedItems(m *models.MultiSearchResponse) []models.UnifiedSearchItem {
if m == nil {
return []models.UnifiedSearchItem{}
}
items := make([]models.UnifiedSearchItem, 0, len(m.Results))
for _, r := range m.Results {
if r.MediaType != "movie" && r.MediaType != "tv" {
continue
}
title := r.Title
if r.MediaType == "tv" {
title = r.Name
}
release := r.ReleaseDate
if r.MediaType == "tv" {
release = r.FirstAirDate
}
poster := BuildAPIImageProxyURL(r.PosterPath, "w300")
tmdbId := r.ID
items = append(items, models.UnifiedSearchItem{
ID: strconv.Itoa(tmdbId),
SourceID: "tmdb_" + strconv.Itoa(tmdbId),
Title: title,
Type: map[string]string{"movie": "movie", "tv": "tv"}[r.MediaType],
ReleaseDate: release,
PosterURL: poster,
Rating: r.VoteAverage,
Description: r.Overview,
ExternalIDs: models.UnifiedExternalIDs{KP: nil, TMDB: &tmdbId, IMDb: ""},
})
}
return items
}
func MapKPSearchToUnifiedItems(kps *KPSearchResponse) []models.UnifiedSearchItem {
if kps == nil {
return []models.UnifiedSearchItem{}
}
items := make([]models.UnifiedSearchItem, 0, len(kps.Films))
for _, f := range kps.Films {
title := f.NameRu
if strings.TrimSpace(title) == "" {
title = f.NameEn
}
poster := f.PosterUrlPreview
if poster == "" {
poster = f.PosterUrl
}
poster = BuildAPIImageProxyURL(poster, "w300")
rating := 0.0
if strings.TrimSpace(f.Rating) != "" {
if v, err := strconv.ParseFloat(f.Rating, 64); err == nil {
rating = v
}
}
kpId := f.FilmId
items = append(items, models.UnifiedSearchItem{
ID: strconv.Itoa(kpId),
SourceID: "kp_" + strconv.Itoa(kpId),
Title: title,
Type: mapKPTypeToUnifiedShort(f.Type),
OriginalType: strings.ToUpper(strings.TrimSpace(f.Type)),
ReleaseDate: yearToDate(f.Year),
PosterURL: poster,
Rating: rating,
Description: f.Description,
ExternalIDs: models.UnifiedExternalIDs{KP: &kpId, TMDB: nil, IMDb: ""},
})
}
return items
}
func mapKPTypeToUnifiedShort(t string) string {
switch strings.ToUpper(strings.TrimSpace(t)) {
case "TV_SERIES", "MINI_SERIES":
return "tv"
default:
return "movie"
}
}
func yearToDate(y string) string {
y = strings.TrimSpace(y)
if y == "" {
return ""
}
return y + "-01-01"
}
func firstCountry(countries []models.ProductionCountry) string {
if len(countries) == 0 {
return ""
}
if strings.TrimSpace(countries[0].Name) != "" {
return countries[0].Name
}
return countries[0].ISO31661
}
package services
import (
"fmt"
"strconv"
"strings"
"neomovies-api/pkg/models"
)
const tmdbImageBase = "https://image.tmdb.org/t/p"
func BuildAPIImageProxyURL(pathOrURL string, size string) string {
if strings.TrimSpace(pathOrURL) == "" {
return ""
}
// Extract type and ID from Kinopoisk URL pattern
// https://kinopoiskapiunofficial.tech/images/posters/{type}/{id}.jpg
if strings.Contains(pathOrURL, "kinopoiskapiunofficial.tech") {
parts := strings.Split(pathOrURL, "/")
if len(parts) >= 2 {
// Find "posters" index
for i, part := range parts {
if part == "posters" && i+2 < len(parts) {
imageType := parts[i+1] // kp, kp_small, kp_big
idWithExt := parts[i+2] // 326.jpg
imageID := strings.TrimSuffix(idWithExt, ".jpg")
// Map size to type if needed
if size == "w1280" || size == "original" {
imageType = "kp_big"
} else if size == "w300" || size == "w185" {
imageType = "kp_small"
}
return fmt.Sprintf("/api/v1/images/%s/%s", imageType, imageID)
}
}
}
}
// Yandex/other absolute URLs - return empty for now
if strings.HasPrefix(pathOrURL, "http://") || strings.HasPrefix(pathOrURL, "https://") {
return ""
}
// TMDB relative path - not supported in new format
return ""
}
func MapTMDBToUnifiedMovie(movie *models.Movie, external *models.ExternalIDs) *models.UnifiedContent {
if movie == nil {
return nil
}
genres := make([]models.UnifiedGenre, 0, len(movie.Genres))
for _, g := range movie.Genres {
name := strings.TrimSpace(g.Name)
id := strings.ToLower(strings.ReplaceAll(name, " ", "-"))
if id == "" {
id = strconv.Itoa(g.ID)
}
genres = append(genres, models.UnifiedGenre{ID: id, Name: name})
}
var imdb string
if external != nil {
imdb = external.IMDbID
}
var budgetPtr *int64
if movie.Budget > 0 {
v := movie.Budget
budgetPtr = &v
}
var revenuePtr *int64
if movie.Revenue > 0 {
v := movie.Revenue
revenuePtr = &v
}
ext := models.UnifiedExternalIDs{
KP: nil,
TMDB: &movie.ID,
IMDb: imdb,
}
return &models.UnifiedContent{
ID: strconv.Itoa(movie.ID),
SourceID: "tmdb_" + strconv.Itoa(movie.ID),
Title: movie.Title,
OriginalTitle: movie.OriginalTitle,
Description: movie.Overview,
ReleaseDate: movie.ReleaseDate,
EndDate: nil,
Type: "movie",
Genres: genres,
Rating: movie.VoteAverage,
PosterURL: BuildAPIImageProxyURL(movie.PosterPath, "w300"),
BackdropURL: BuildAPIImageProxyURL(movie.BackdropPath, "w1280"),
Director: "",
Cast: []models.UnifiedCastMember{},
Duration: movie.Runtime,
Country: firstCountry(movie.ProductionCountries),
Language: movie.OriginalLanguage,
Budget: budgetPtr,
Revenue: revenuePtr,
IMDbID: imdb,
ExternalIDs: ext,
}
}
func MapTMDBTVToUnified(tv *models.TVShow, external *models.ExternalIDs) *models.UnifiedContent {
if tv == nil {
return nil
}
genres := make([]models.UnifiedGenre, 0, len(tv.Genres))
for _, g := range tv.Genres {
name := strings.TrimSpace(g.Name)
id := strings.ToLower(strings.ReplaceAll(name, " ", "-"))
if id == "" {
id = strconv.Itoa(g.ID)
}
genres = append(genres, models.UnifiedGenre{ID: id, Name: name})
}
var imdb string
if external != nil {
imdb = external.IMDbID
}
endDate := (*string)(nil)
if strings.TrimSpace(tv.LastAirDate) != "" {
v := tv.LastAirDate
endDate = &v
}
ext := models.UnifiedExternalIDs{
KP: nil,
TMDB: &tv.ID,
IMDb: imdb,
}
duration := 0
if len(tv.EpisodeRunTime) > 0 {
duration = tv.EpisodeRunTime[0]
}
unified := &models.UnifiedContent{
ID: strconv.Itoa(tv.ID),
SourceID: "tmdb_" + strconv.Itoa(tv.ID),
Title: tv.Name,
OriginalTitle: tv.OriginalName,
Description: tv.Overview,
ReleaseDate: tv.FirstAirDate,
EndDate: endDate,
Type: "tv",
Genres: genres,
Rating: tv.VoteAverage,
PosterURL: BuildAPIImageProxyURL(tv.PosterPath, "w300"),
BackdropURL: BuildAPIImageProxyURL(tv.BackdropPath, "w1280"),
Director: "",
Cast: []models.UnifiedCastMember{},
Duration: duration,
Country: firstCountry(tv.ProductionCountries),
Language: tv.OriginalLanguage,
Budget: nil,
Revenue: nil,
IMDbID: imdb,
ExternalIDs: ext,
}
// Map seasons basic info
if len(tv.Seasons) > 0 {
unified.Seasons = make([]models.UnifiedSeason, 0, len(tv.Seasons))
for _, s := range tv.Seasons {
unified.Seasons = append(unified.Seasons, models.UnifiedSeason{
ID: strconv.Itoa(s.ID),
SourceID: "tmdb_" + strconv.Itoa(s.ID),
Name: s.Name,
SeasonNumber: s.SeasonNumber,
EpisodeCount: s.EpisodeCount,
ReleaseDate: s.AirDate,
PosterURL: BuildAPIImageProxyURL(s.PosterPath, "w300"),
})
}
}
return unified
}
func MapTMDBMultiToUnifiedItems(m *models.MultiSearchResponse) []models.UnifiedSearchItem {
if m == nil {
return []models.UnifiedSearchItem{}
}
items := make([]models.UnifiedSearchItem, 0, len(m.Results))
for _, r := range m.Results {
if r.MediaType != "movie" && r.MediaType != "tv" {
continue
}
title := r.Title
if r.MediaType == "tv" {
title = r.Name
}
release := r.ReleaseDate
if r.MediaType == "tv" {
release = r.FirstAirDate
}
poster := BuildAPIImageProxyURL(r.PosterPath, "w300")
tmdbId := r.ID
items = append(items, models.UnifiedSearchItem{
ID: strconv.Itoa(tmdbId),
SourceID: "tmdb_" + strconv.Itoa(tmdbId),
Title: title,
Type: map[string]string{"movie": "movie", "tv": "tv"}[r.MediaType],
ReleaseDate: release,
PosterURL: poster,
Rating: r.VoteAverage,
Description: r.Overview,
ExternalIDs: models.UnifiedExternalIDs{KP: nil, TMDB: &tmdbId, IMDb: ""},
})
}
return items
}
func MapKPSearchToUnifiedItems(kps *KPSearchResponse) []models.UnifiedSearchItem {
if kps == nil {
return []models.UnifiedSearchItem{}
}
items := make([]models.UnifiedSearchItem, 0, len(kps.Films))
for _, f := range kps.Films {
title := f.NameRu
if strings.TrimSpace(title) == "" {
title = f.NameEn
}
poster := f.PosterUrlPreview
if poster == "" {
poster = f.PosterUrl
}
poster = BuildAPIImageProxyURL(poster, "w300")
rating := 0.0
if strings.TrimSpace(f.Rating) != "" {
if v, err := strconv.ParseFloat(f.Rating, 64); err == nil {
rating = v
}
}
kpId := f.FilmId
items = append(items, models.UnifiedSearchItem{
ID: strconv.Itoa(kpId),
SourceID: "kp_" + strconv.Itoa(kpId),
Title: title,
Type: mapKPTypeToUnifiedShort(f.Type),
OriginalType: strings.ToUpper(strings.TrimSpace(f.Type)),
ReleaseDate: yearToDate(f.Year),
PosterURL: poster,
Rating: rating,
Description: f.Description,
ExternalIDs: models.UnifiedExternalIDs{KP: &kpId, TMDB: nil, IMDb: ""},
})
}
return items
}
func mapKPTypeToUnifiedShort(t string) string {
switch strings.ToUpper(strings.TrimSpace(t)) {
case "TV_SERIES", "MINI_SERIES":
return "tv"
default:
return "movie"
}
}
func yearToDate(y string) string {
y = strings.TrimSpace(y)
if y == "" {
return ""
}
return y + "-01-01"
}
func firstCountry(countries []models.ProductionCountry) string {
if len(countries) == 0 {
return ""
}
if strings.TrimSpace(countries[0].Name) != "" {
return countries[0].Name
}
return countries[0].ISO31661
}