From 28555a83e1b706cb2d302f75fd5cce4679ab4b9d Mon Sep 17 00:00:00 2001 From: Erno Date: Sat, 18 Oct 2025 20:21:13 +0000 Subject: [PATCH] feat: Update player API to use id_type in path All Russian players now use format: /players/{player}/{id_type}/{id} - id_type can be kp (Kinopoisk) or imdb - Alloha, Lumex, Vibix, HDVB support both ID types - Added validation for id_type parameter - Updated handlers to parse id_type from path --- .env.example | 48 +++--- main.go | 14 +- pkg/config/config.go | 6 + pkg/config/vars.go | 3 + pkg/handlers/docs.go | 2 +- pkg/handlers/players.go | 175 +++++++++++++++------ pkg/handlers/search.go | 40 ++++- pkg/models/movie.go | 4 + pkg/services/kinopoisk.go | 261 ++++++++++++++++++++++++++++++++ pkg/services/kp_mapper.go | 309 ++++++++++++++++++++++++++++++++++++++ pkg/services/movie.go | 46 +++++- pkg/services/tv.go | 18 ++- 12 files changed, 838 insertions(+), 88 deletions(-) create mode 100644 pkg/services/kinopoisk.go create mode 100644 pkg/services/kp_mapper.go diff --git a/.env.example b/.env.example index 961781c..441e4ac 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,34 @@ -# Required -MONGO_URI= -MONGO_DB_NAME=database -TMDB_ACCESS_TOKEN= -JWT_SECRET= +MONGO_URI=mongodb://localhost:27017/neomovies +MONGO_DB_NAME=neomovies -# Service -PORT=3000 -BASE_URL=http://localhost:3000 -NODE_ENV=development +TMDB_ACCESS_TOKEN=your_tmdb_access_token_here -# Email (Gmail) -GMAIL_USER= -GMAIL_APP_PASSWORD= +KPAPI_KEY=920aaf6a-9f64-46f7-bda7-209fb1069440 +KPAPI_BASE_URL=https://kinopoiskapiunofficial.tech/api -# Players -LUMEX_URL= -ALLOHA_TOKEN= +HDVB_TOKEN=b9ae5f8c4832244060916af4aa9d1939 + +VIBIX_HOST=https://vibix.org +VIBIX_TOKEN=18745|NzecUXT4gikPUtFkSEFlDLPmr9kWnQACTo1N0Ixq9240bcf1 + +LUMEX_URL=https://p.lumex.space + +ALLOHA_TOKEN=your_alloha_token -# Torrents (RedAPI) REDAPI_BASE_URL=http://redapi.cfhttp.top -REDAPI_KEY= +REDAPI_KEY=your_redapi_key -# Google OAuth -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= +JWT_SECRET=your_jwt_secret_key_here + +GMAIL_USER=your_gmail@gmail.com +GMAIL_APP_PASSWORD=your_gmail_app_password + +GOOGLE_CLIENT_ID=your_google_client_id +GOOGLE_CLIENT_SECRET=your_google_client_secret GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback -FRONTEND_URL=http://localhost:3001 \ No newline at end of file + +BASE_URL=http://localhost:3000 +FRONTEND_URL=http://localhost:3001 + +PORT=3000 +NODE_ENV=development diff --git a/main.go b/main.go index dec7ab7..87ab5ff 100644 --- a/main.go +++ b/main.go @@ -32,11 +32,12 @@ func main() { defer database.Disconnect() tmdbService := services.NewTMDBService(cfg.TMDBAccessToken) + kpService := services.NewKinopoiskService(cfg.KPAPIKey, cfg.KPAPIBaseURL) emailService := services.NewEmailService(cfg) authService := services.NewAuthService(db, cfg.JWTSecret, emailService, cfg.BaseURL, cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.GoogleRedirectURL, cfg.FrontendURL) - movieService := services.NewMovieService(db, tmdbService) - tvService := services.NewTVService(db, tmdbService) + movieService := services.NewMovieService(db, tmdbService, kpService) + tvService := services.NewTVService(db, tmdbService, kpService) favoritesService := services.NewFavoritesService(db, tmdbService) torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey) reactionsService := services.NewReactionsService(db) @@ -46,7 +47,7 @@ func main() { tvHandler := appHandlers.NewTVHandler(tvService) favoritesHandler := appHandlers.NewFavoritesHandler(favoritesService, cfg) docsHandler := appHandlers.NewDocsHandler() - searchHandler := appHandlers.NewSearchHandler(tmdbService) + searchHandler := appHandlers.NewSearchHandler(tmdbService, kpService) categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService) playersHandler := appHandlers.NewPlayersHandler(cfg) torrentsHandler := appHandlers.NewTorrentsHandler(torrentService, tmdbService) @@ -75,12 +76,13 @@ func main() { api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET") api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).Methods("GET") - api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET") - api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") - api.HandleFunc("/players/vibix/{imdb_id}", playersHandler.GetVibixPlayer).Methods("GET") + api.HandleFunc("/players/alloha/{id_type}/{id}", playersHandler.GetAllohaPlayer).Methods("GET") + api.HandleFunc("/players/lumex/{id_type}/{id}", playersHandler.GetLumexPlayer).Methods("GET") + api.HandleFunc("/players/vibix/{id_type}/{id}", playersHandler.GetVibixPlayer).Methods("GET") api.HandleFunc("/players/vidsrc/{media_type}/{imdb_id}", playersHandler.GetVidsrcPlayer).Methods("GET") api.HandleFunc("/players/vidlink/movie/{imdb_id}", playersHandler.GetVidlinkMoviePlayer).Methods("GET") api.HandleFunc("/players/vidlink/tv/{tmdb_id}", playersHandler.GetVidlinkTVPlayer).Methods("GET") + api.HandleFunc("/players/hdvb/{id_type}/{id}", playersHandler.GetHDVBPlayer).Methods("GET") api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") diff --git a/pkg/config/config.go b/pkg/config/config.go index b05d585..4af041b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -25,6 +25,9 @@ type Config struct { FrontendURL string VibixHost string VibixToken string + KPAPIKey string + HDVBToken string + KPAPIBaseURL string } func New() *Config { @@ -50,6 +53,9 @@ func New() *Config { FrontendURL: getEnv(EnvFrontendURL, ""), VibixHost: getEnv(EnvVibixHost, DefaultVibixHost), VibixToken: getEnv(EnvVibixToken, ""), + KPAPIKey: getEnv(EnvKPAPIKey, ""), + HDVBToken: getEnv(EnvHDVBToken, ""), + KPAPIBaseURL: getEnv("KPAPI_BASE_URL", DefaultKPAPIBase), } } diff --git a/pkg/config/vars.go b/pkg/config/vars.go index 470f2ca..ba7015f 100644 --- a/pkg/config/vars.go +++ b/pkg/config/vars.go @@ -20,6 +20,8 @@ const ( EnvFrontendURL = "FRONTEND_URL" EnvVibixHost = "VIBIX_HOST" EnvVibixToken = "VIBIX_TOKEN" + EnvKPAPIKey = "KPAPI_KEY" + EnvHDVBToken = "HDVB_TOKEN" // Default values DefaultJWTSecret = "your-secret-key" @@ -29,6 +31,7 @@ const ( 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" diff --git a/pkg/handlers/docs.go b/pkg/handlers/docs.go index 2a10ee2..cd3a8be 100644 --- a/pkg/handlers/docs.go +++ b/pkg/handlers/docs.go @@ -39,7 +39,6 @@ func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) { func (h *DocsHandler) ServeDocs(w http.ResponseWriter, r *http.Request) { baseURL := determineBaseURL(r) - // Use absolute SpecURL so the library does not try to read a local file path htmlContent, err := scalar.ApiReferenceHTML(&scalar.Options{ SpecURL: fmt.Sprintf("%s/openapi.json", baseURL), CustomOptions: scalar.CustomOptions{ @@ -56,6 +55,7 @@ func (h *DocsHandler) ServeDocs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") fmt.Fprintln(w, htmlContent) } diff --git a/pkg/handlers/players.go b/pkg/handlers/players.go index dc890d0..9198923 100644 --- a/pkg/handlers/players.go +++ b/pkg/handlers/players.go @@ -30,16 +30,22 @@ func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) log.Printf("GetAllohaPlayer called: %s %s", r.Method, r.URL.Path) vars := mux.Vars(r) - log.Printf("Route vars: %+v", vars) + idType := vars["id_type"] + id := vars["id"] - imdbID := vars["imdb_id"] - if imdbID == "" { - log.Printf("Error: imdb_id is empty") - http.Error(w, "imdb_id path param is required", http.StatusBadRequest) + if idType == "" || id == "" { + log.Printf("Error: id_type or id is empty") + http.Error(w, "id_type and id are required", http.StatusBadRequest) return } - log.Printf("Processing imdb_id: %s", imdbID) + if idType != "kp" && idType != "imdb" { + log.Printf("Error: invalid id_type: %s", idType) + http.Error(w, "id_type must be 'kp' or 'imdb'", http.StatusBadRequest) + return + } + + log.Printf("Processing %s ID: %s", idType, id) if h.config.AllohaToken == "" { log.Printf("Error: ALLOHA_TOKEN is missing") @@ -47,7 +53,7 @@ func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) return } - idParam := fmt.Sprintf("imdb=%s", url.QueryEscape(imdbID)) + idParam := fmt.Sprintf("%s=%s", idType, url.QueryEscape(id)) apiURL := fmt.Sprintf("https://api.alloha.tv/?token=%s&%s", h.config.AllohaToken, idParam) log.Printf("Calling Alloha API: %s", apiURL) @@ -104,7 +110,7 @@ func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) // Используем iframe URL из API iframeCode := allohaResponse.Data.Iframe - + // Если это не HTML код, а просто URL var playerURL string if !strings.Contains(iframeCode, "<") { @@ -129,23 +135,29 @@ func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "text/html") w.Write([]byte(htmlDoc)) - log.Printf("Successfully served Alloha player for imdb_id: %s", imdbID) + log.Printf("Successfully served Alloha player for %s: %s", idType, id) } func (h *PlayersHandler) GetLumexPlayer(w http.ResponseWriter, r *http.Request) { log.Printf("GetLumexPlayer called: %s %s", r.Method, r.URL.Path) vars := mux.Vars(r) - log.Printf("Route vars: %+v", vars) + idType := vars["id_type"] + id := vars["id"] - imdbID := vars["imdb_id"] - if imdbID == "" { - log.Printf("Error: imdb_id is empty") - http.Error(w, "imdb_id path param is required", http.StatusBadRequest) + if idType == "" || id == "" { + log.Printf("Error: id_type or id is empty") + http.Error(w, "id_type and id are required", http.StatusBadRequest) return } - log.Printf("Processing imdb_id: %s", imdbID) + if idType != "kp" && idType != "imdb" { + log.Printf("Error: invalid id_type: %s", idType) + http.Error(w, "id_type must be 'kp' or 'imdb'", http.StatusBadRequest) + return + } + + log.Printf("Processing %s ID: %s", idType, id) if h.config.LumexURL == "" { log.Printf("Error: LUMEX_URL is missing") @@ -153,9 +165,15 @@ func (h *PlayersHandler) GetLumexPlayer(w http.ResponseWriter, r *http.Request) return } - // Lumex использует только IMDb ID без season/episode - playerURL := fmt.Sprintf("%s?imdb_id=%s", h.config.LumexURL, imdbID) - log.Printf("🔗 Lumex URL: %s", playerURL) + var paramName string + if idType == "kp" { + paramName = "kinopoisk_id" + } else { + paramName = "imdb_id" + } + + playerURL := fmt.Sprintf("%s?%s=%s", h.config.LumexURL, paramName, id) + log.Printf("Lumex URL: %s", playerURL) iframe := fmt.Sprintf(``, playerURL) htmlDoc := fmt.Sprintf(`Lumex Player%s`, iframe) @@ -163,23 +181,29 @@ func (h *PlayersHandler) GetLumexPlayer(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "text/html") w.Write([]byte(htmlDoc)) - log.Printf("Successfully served Lumex player for imdb_id: %s", imdbID) + log.Printf("Successfully served Lumex player for %s: %s", idType, id) } func (h *PlayersHandler) GetVibixPlayer(w http.ResponseWriter, r *http.Request) { log.Printf("GetVibixPlayer called: %s %s", r.Method, r.URL.Path) vars := mux.Vars(r) - log.Printf("Route vars: %+v", vars) + idType := vars["id_type"] + id := vars["id"] - imdbID := vars["imdb_id"] - if imdbID == "" { - log.Printf("Error: imdb_id is empty") - http.Error(w, "imdb_id path param is required", http.StatusBadRequest) + if idType == "" || id == "" { + log.Printf("Error: id_type or id is empty") + http.Error(w, "id_type and id are required", http.StatusBadRequest) return } - log.Printf("Processing imdb_id: %s", imdbID) + if idType != "kp" && idType != "imdb" { + log.Printf("Error: invalid id_type: %s", idType) + http.Error(w, "id_type must be 'kp' or 'imdb'", http.StatusBadRequest) + return + } + + log.Printf("Processing %s ID: %s", idType, id) if h.config.VibixToken == "" { log.Printf("Error: VIBIX_TOKEN is missing") @@ -192,7 +216,14 @@ func (h *PlayersHandler) GetVibixPlayer(w http.ResponseWriter, r *http.Request) vibixHost = "https://vibix.org" } - apiURL := fmt.Sprintf("%s/api/v1/publisher/videos/imdb/%s", vibixHost, imdbID) + var endpoint string + if idType == "kp" { + endpoint = "kinopoisk" + } else { + endpoint = "imdb" + } + + apiURL := fmt.Sprintf("%s/api/v1/publisher/videos/%s/%s", vibixHost, endpoint, id) log.Printf("Calling Vibix API: %s", apiURL) req, err := http.NewRequest("GET", apiURL, nil) @@ -203,7 +234,7 @@ func (h *PlayersHandler) GetVibixPlayer(w http.ResponseWriter, r *http.Request) } req.Header.Set("Accept", "application/json") - req.Header.Set("Authorization", "Bearer "+h.config.VibixToken) + req.Header.Set("Authorization", h.config.VibixToken) req.Header.Set("X-CSRF-TOKEN", "") client := &http.Client{Timeout: 8 * time.Second} @@ -259,7 +290,7 @@ func (h *PlayersHandler) GetVibixPlayer(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "text/html") w.Write([]byte(htmlDoc)) - log.Printf("Successfully served Vibix player for imdb_id: %s", imdbID) + log.Printf("Successfully served Vibix player for %s: %s", idType, id) } // GetRgShowsPlayer handles RgShows streaming requests @@ -463,16 +494,16 @@ func (h *PlayersHandler) GetStreamAPI(w http.ResponseWriter, r *http.Request) { // GetVidsrcPlayer handles Vidsrc.to player (uses IMDb ID for both movies and TV shows) func (h *PlayersHandler) GetVidsrcPlayer(w http.ResponseWriter, r *http.Request) { log.Printf("GetVidsrcPlayer called: %s %s", r.Method, r.URL.Path) - + vars := mux.Vars(r) imdbId := vars["imdb_id"] mediaType := vars["media_type"] // "movie" or "tv" - + if imdbId == "" || mediaType == "" { http.Error(w, "imdb_id and media_type are required", http.StatusBadRequest) return } - + var playerURL string if mediaType == "movie" { playerURL = fmt.Sprintf("https://vidsrc.to/embed/movie/%s", imdbId) @@ -488,72 +519,116 @@ func (h *PlayersHandler) GetVidsrcPlayer(w http.ResponseWriter, r *http.Request) http.Error(w, "Invalid media_type. Use 'movie' or 'tv'", http.StatusBadRequest) return } - + log.Printf("Generated Vidsrc URL: %s", playerURL) - + // Используем общий шаблон с кастомными контролами htmlDoc := getPlayerWithControlsHTML(playerURL, "Vidsrc Player") - + w.Header().Set("Content-Type", "text/html") w.Write([]byte(htmlDoc)) - + log.Printf("Successfully served Vidsrc player for %s: %s", mediaType, imdbId) } // GetVidlinkMoviePlayer handles vidlink.pro player for movies (uses IMDb ID) func (h *PlayersHandler) GetVidlinkMoviePlayer(w http.ResponseWriter, r *http.Request) { log.Printf("GetVidlinkMoviePlayer called: %s %s", r.Method, r.URL.Path) - + vars := mux.Vars(r) imdbId := vars["imdb_id"] - + if imdbId == "" { http.Error(w, "imdb_id is required", http.StatusBadRequest) return } - + playerURL := fmt.Sprintf("https://vidlink.pro/movie/%s", imdbId) - + log.Printf("Generated Vidlink Movie URL: %s", playerURL) - + // Используем общий шаблон с кастомными контролами htmlDoc := getPlayerWithControlsHTML(playerURL, "Vidlink Player") - + w.Header().Set("Content-Type", "text/html") w.Write([]byte(htmlDoc)) - + log.Printf("Successfully served Vidlink movie player: %s", imdbId) } // GetVidlinkTVPlayer handles vidlink.pro player for TV shows (uses TMDB ID) +func (h *PlayersHandler) GetHDVBPlayer(w http.ResponseWriter, r *http.Request) { + log.Printf("GetHDVBPlayer called: %s %s", r.Method, r.URL.Path) + + vars := mux.Vars(r) + idType := vars["id_type"] + id := vars["id"] + + if idType == "" || id == "" { + log.Printf("Error: id_type or id is empty") + http.Error(w, "id_type and id are required", http.StatusBadRequest) + return + } + + if idType != "kp" && idType != "imdb" { + log.Printf("Error: invalid id_type: %s", idType) + http.Error(w, "id_type must be 'kp' or 'imdb'", http.StatusBadRequest) + return + } + + log.Printf("Processing %s ID: %s", idType, id) + + if h.config.HDVBToken == "" { + log.Printf("Error: HDVB_TOKEN is missing") + http.Error(w, "Server misconfiguration: HDVB_TOKEN missing", http.StatusInternalServerError) + return + } + + var playerURL string + if idType == "kp" { + playerURL = fmt.Sprintf("https://apivb.com/api/videos.json?id_kp=%s&token=%s", id, h.config.HDVBToken) + } else { + playerURL = fmt.Sprintf("https://apivb.com/api/videos.json?imdb_id=%s&token=%s", id, h.config.HDVBToken) + } + log.Printf("Generated HDVB URL: %s", playerURL) + + iframe := fmt.Sprintf(``, playerURL) + htmlDoc := fmt.Sprintf(`HDVB Player%s`, iframe) + + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(htmlDoc)) + + log.Printf("Successfully served HDVB player for %s: %s", idType, id) +} + func (h *PlayersHandler) GetVidlinkTVPlayer(w http.ResponseWriter, r *http.Request) { log.Printf("GetVidlinkTVPlayer called: %s %s", r.Method, r.URL.Path) - + vars := mux.Vars(r) tmdbId := vars["tmdb_id"] - + if tmdbId == "" { http.Error(w, "tmdb_id is required", http.StatusBadRequest) return } - + season := r.URL.Query().Get("season") episode := r.URL.Query().Get("episode") if season == "" || episode == "" { http.Error(w, "season and episode are required for TV shows", http.StatusBadRequest) return } - + playerURL := fmt.Sprintf("https://vidlink.pro/tv/%s/%s/%s", tmdbId, season, episode) - + log.Printf("Generated Vidlink TV URL: %s", playerURL) - + // Используем общий шаблон с кастомными контролами htmlDoc := getPlayerWithControlsHTML(playerURL, "Vidlink Player") - + w.Header().Set("Content-Type", "text/html") w.Write([]byte(htmlDoc)) - + log.Printf("Successfully served Vidlink TV player: %s S%sE%s", tmdbId, season, episode) } diff --git a/pkg/handlers/search.go b/pkg/handlers/search.go index 3cb28f2..ac08f12 100644 --- a/pkg/handlers/search.go +++ b/pkg/handlers/search.go @@ -10,11 +10,13 @@ import ( type SearchHandler struct { tmdbService *services.TMDBService + kpService *services.KinopoiskService } -func NewSearchHandler(tmdbService *services.TMDBService) *SearchHandler { +func NewSearchHandler(tmdbService *services.TMDBService, kpService *services.KinopoiskService) *SearchHandler { return &SearchHandler{ tmdbService: tmdbService, + kpService: kpService, } } @@ -28,6 +30,42 @@ func (h *SearchHandler) MultiSearch(w http.ResponseWriter, r *http.Request) { page := getIntQuery(r, "page", 1) language := GetLanguage(r) + if services.ShouldUseKinopoisk(language) && h.kpService != nil { + kpSearch, err := h.kpService.SearchFilms(query, page) + if err == nil { + 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 + } + } + results, err := h.tmdbService.SearchMulti(query, page, language) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/pkg/models/movie.go b/pkg/models/movie.go index 0253f97..3ead6bc 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -40,6 +40,7 @@ type Movie struct { 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"` @@ -76,6 +77,8 @@ type TVShow struct { 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 для мультипоиска @@ -119,6 +122,7 @@ type GenresResponse struct { type ExternalIDs struct { ID int `json:"id"` IMDbID string `json:"imdb_id"` + KinopoiskID int `json:"kinopoisk_id,omitempty"` TVDBID int `json:"tvdb_id,omitempty"` WikidataID string `json:"wikidata_id"` FacebookID string `json:"facebook_id"` diff --git a/pkg/services/kinopoisk.go b/pkg/services/kinopoisk.go new file mode 100644 index 0000000..a254345 --- /dev/null +++ b/pkg/services/kinopoisk.go @@ -0,0 +1,261 @@ +package services + +import ( + "encoding/json" + "fmt" + "net/http" + "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, 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, 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) +} diff --git a/pkg/services/kp_mapper.go b/pkg/services/kp_mapper.go new file mode 100644 index 0000000..3b8dc91 --- /dev/null +++ b/pkg/services/kp_mapper.go @@ -0,0 +1,309 @@ +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, + Tagline: kpFilm.Slogan, + Status: status, + InProduction: !kpFilm.Completed, + ImdbID: kpFilm.ImdbId, + KinopoiskID: kpFilm.KinopoiskId, + } +} + +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) +} diff --git a/pkg/services/movie.go b/pkg/services/movie.go index 6450067..69be773 100644 --- a/pkg/services/movie.go +++ b/pkg/services/movie.go @@ -7,28 +7,54 @@ import ( ) type MovieService struct { - tmdb *TMDBService + tmdb *TMDBService + kpService *KinopoiskService } -func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService { +func NewMovieService(db *mongo.Database, tmdb *TMDBService, kpService *KinopoiskService) *MovieService { return &MovieService{ - tmdb: tmdb, + tmdb: tmdb, + kpService: kpService, } } func (s *MovieService) Search(query string, page int, language, region string, year int) (*models.TMDBResponse, error) { + if ShouldUseKinopoisk(language) && s.kpService != nil { + kpSearch, err := s.kpService.SearchFilms(query, page) + if err == nil { + return MapKPSearchToTMDBResponse(kpSearch), nil + } + } return s.tmdb.SearchMovies(query, page, language, region, year) } func (s *MovieService) GetByID(id int, language string) (*models.Movie, error) { + if ShouldUseKinopoisk(language) && s.kpService != nil { + kpFilm, err := s.kpService.GetFilmByKinopoiskId(id) + if err == nil { + return MapKPFilmToTMDBMovie(kpFilm), nil + } + } return s.tmdb.GetMovie(id, language) } func (s *MovieService) GetPopular(page int, language, region string) (*models.TMDBResponse, error) { + if ShouldUseKinopoisk(language) && s.kpService != nil { + kpTop, err := s.kpService.GetTopFilms("TOP_100_POPULAR_FILMS", page) + if err == nil { + return MapKPSearchToTMDBResponse(kpTop), nil + } + } return s.tmdb.GetPopularMovies(page, language, region) } func (s *MovieService) GetTopRated(page int, language, region string) (*models.TMDBResponse, error) { + if ShouldUseKinopoisk(language) && s.kpService != nil { + kpTop, err := s.kpService.GetTopFilms("TOP_250_BEST_FILMS", page) + if err == nil { + return MapKPSearchToTMDBResponse(kpTop), nil + } + } return s.tmdb.GetTopRatedMovies(page, language, region) } @@ -49,5 +75,17 @@ func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBRe } func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) { - return s.tmdb.GetMovieExternalIDs(id) + tmdbIDs, err := s.tmdb.GetMovieExternalIDs(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 } diff --git a/pkg/services/tv.go b/pkg/services/tv.go index 18dea61..aa575b4 100644 --- a/pkg/services/tv.go +++ b/pkg/services/tv.go @@ -7,14 +7,16 @@ import ( ) type TVService struct { - db *mongo.Database - tmdb *TMDBService + db *mongo.Database + tmdb *TMDBService + kpService *KinopoiskService } -func NewTVService(db *mongo.Database, tmdb *TMDBService) *TVService { +func NewTVService(db *mongo.Database, tmdb *TMDBService, kpService *KinopoiskService) *TVService { return &TVService{ - db: db, - tmdb: tmdb, + db: db, + tmdb: tmdb, + kpService: kpService, } } @@ -23,6 +25,12 @@ func (s *TVService) Search(query string, page int, language string, year int) (* } func (s *TVService) GetByID(id int, language string) (*models.TVShow, error) { + if ShouldUseKinopoisk(language) && s.kpService != nil { + kpFilm, err := s.kpService.GetFilmByKinopoiskId(id) + if err == nil && kpFilm != nil { + return MapKPFilmToTVShow(kpFilm), nil + } + } return s.tmdb.GetTVShow(id, language) }