From 04fe3f3925d468fb0fc2d7fc38c080d55cbde784 Mon Sep 17 00:00:00 2001 From: Erno Date: Tue, 21 Oct 2025 15:35:20 +0000 Subject: [PATCH] feat(players/alloha): add meta endpoint for seasons/episodes; perf: http clients/transport; docs: update --- README.md | 15 +++--- api/index.go | 5 +- main.go | 2 +- pkg/handlers/categories.go | 51 +++++++++++++------- pkg/handlers/favorites.go | 22 +++++---- pkg/handlers/players.go | 98 +++++++++++++++++++++++++++++++++++++- pkg/services/tmdb.go | 28 ++++++++--- 7 files changed, 173 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index a0a206e..1e08193 100644 --- a/README.md +++ b/README.md @@ -278,18 +278,15 @@ curl -X POST https://api.neomovies.ru/api/v1/auth/login \ }' ``` -### Поиск фильмов +### Поиск (унифицированный) ```bash -# Поиск фильмов -curl "https://api.neomovies.ru/api/v1/movies/search?query=marvel&page=1" +# Мультипоиск (источник обязателен) +curl "https://api.neomovies.ru/api/v1/search?query=matrix&source=tmdb&page=1" -# Детали фильма -curl "https://api.neomovies.ru/api/v1/movies/550" - -# Добавить в избранное (с JWT токеном) -curl -X POST https://api.neomovies.ru/api/v1/favorites/550 \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" +# Детали по униф. ID +curl "https://api.neomovies.ru/api/v1/movie/tmdb_550" +curl "https://api.neomovies.ru/api/v1/tv/kp_61365" ``` ### Поиск торрентов diff --git a/api/index.go b/api/index.go index 2207a27..7056fa7 100644 --- a/api/index.go +++ b/api/index.go @@ -69,7 +69,7 @@ func Handler(w http.ResponseWriter, r *http.Request) { docsHandler := handlersPkg.NewDocsHandler() searchHandler := handlersPkg.NewSearchHandler(tmdbService, kpService) unifiedHandler := handlersPkg.NewUnifiedHandler(tmdbService, kpService) - categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService) + categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService).WithKinopoisk(kpService) playersHandler := handlersPkg.NewPlayersHandler(globalCfg) torrentsHandler := handlersPkg.NewTorrentsHandler(torrentService, tmdbService) reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService) @@ -96,7 +96,8 @@ func Handler(w http.ResponseWriter, r *http.Request) { api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET") api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).Methods("GET") - api.HandleFunc("/players/alloha/{id_type}/{id}", playersHandler.GetAllohaPlayer).Methods("GET") + api.HandleFunc("/players/alloha/{id_type}/{id}", playersHandler.GetAllohaPlayer).Methods("GET") + api.HandleFunc("/players/alloha/meta/kp/{kp_id}", playersHandler.GetAllohaMetaByKP).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/hdvb/{id_type}/{id}", playersHandler.GetHDVBPlayer).Methods("GET") diff --git a/main.go b/main.go index 4877d19..3136546 100644 --- a/main.go +++ b/main.go @@ -49,7 +49,7 @@ func main() { docsHandler := appHandlers.NewDocsHandler() searchHandler := appHandlers.NewSearchHandler(tmdbService, kpService) unifiedHandler := appHandlers.NewUnifiedHandler(tmdbService, kpService) - categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService) + categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService).WithKinopoisk(kpService) playersHandler := appHandlers.NewPlayersHandler(cfg) torrentsHandler := appHandlers.NewTorrentsHandler(torrentService, tmdbService) reactionsHandler := appHandlers.NewReactionsHandler(reactionsService) diff --git a/pkg/handlers/categories.go b/pkg/handlers/categories.go index d8098c1..a393051 100644 --- a/pkg/handlers/categories.go +++ b/pkg/handlers/categories.go @@ -12,13 +12,18 @@ import ( ) type CategoriesHandler struct { - tmdbService *services.TMDBService + tmdbService *services.TMDBService + kpService *services.KinopoiskService } func NewCategoriesHandler(tmdbService *services.TMDBService) *CategoriesHandler { - return &CategoriesHandler{ - tmdbService: tmdbService, - } + // Для совместимости, 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 { @@ -28,14 +33,14 @@ type Category struct { } func (h *CategoriesHandler) GetCategories(w http.ResponseWriter, r *http.Request) { - // Получаем все жанры - genresResponse, err := h.tmdbService.GetAllGenres() + // Получаем все жанры + 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) @@ -67,7 +72,7 @@ func (h *CategoriesHandler) GetMediaByCategory(w http.ResponseWriter, r *http.Re language = "ru-RU" } - mediaType := r.URL.Query().Get("type") + mediaType := r.URL.Query().Get("type") if mediaType == "" { mediaType = "movie" // По умолчанию фильмы для обратной совместимости } @@ -77,16 +82,28 @@ func (h *CategoriesHandler) GetMediaByCategory(w http.ResponseWriter, r *http.Re return } - var data interface{} - var err2 error + source := r.URL.Query().Get("source") // "kp" | "tmdb" + var data interface{} + var err2 error - if mediaType == "movie" { - // Используем discover API для получения фильмов по жанру - data, err2 = h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language) - } else { - // Используем discover API для получения сериалов по жанру - data, err2 = h.tmdbService.DiscoverTVByGenre(categoryID, page, language) - } + 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) diff --git a/pkg/handlers/favorites.go b/pkg/handlers/favorites.go index 7fc42f7..de4e6a9 100644 --- a/pkg/handlers/favorites.go +++ b/pkg/handlers/favorites.go @@ -1,17 +1,18 @@ package handlers import ( - "encoding/json" - "fmt" - "io" - "net/http" + "encoding/json" + "fmt" + "io" + "net/http" + "time" - "github.com/gorilla/mux" + "github.com/gorilla/mux" - "neomovies-api/pkg/config" - "neomovies-api/pkg/middleware" - "neomovies-api/pkg/models" - "neomovies-api/pkg/services" + "neomovies-api/pkg/config" + "neomovies-api/pkg/middleware" + "neomovies-api/pkg/models" + "neomovies-api/pkg/services" ) type FavoritesHandler struct { @@ -177,7 +178,8 @@ func (h *FavoritesHandler) fetchMediaInfoRussian(mediaID, mediaType string) (*mo url = fmt.Sprintf("https://api.themoviedb.org/3/tv/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken) } - resp, err := http.Get(url) + 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) } diff --git a/pkg/handlers/players.go b/pkg/handlers/players.go index 650e781..a42968d 100644 --- a/pkg/handlers/players.go +++ b/pkg/handlers/players.go @@ -58,7 +58,8 @@ func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) apiURL := fmt.Sprintf("https://api.alloha.tv/?token=%s&%s", h.config.AllohaToken, idParam) log.Printf("Calling Alloha API: %s", apiURL) - resp, err := http.Get(apiURL) + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Get(apiURL) if err != nil { log.Printf("Error calling Alloha API: %v", err) http.Error(w, "Failed to fetch from Alloha API", http.StatusInternalServerError) @@ -139,6 +140,98 @@ func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) log.Printf("Successfully served Alloha player for %s: %s", idType, id) } +// GetAllohaMetaByKP returns seasons/episodes meta for Alloha by kinopoisk_id +func (h *PlayersHandler) GetAllohaMetaByKP(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + kpID := vars["kp_id"] + if strings.TrimSpace(kpID) == "" { + http.Error(w, "kp_id is required", http.StatusBadRequest) + return + } + if h.config.AllohaToken == "" { + http.Error(w, "Server misconfiguration: ALLOHA_TOKEN missing", http.StatusInternalServerError) + return + } + + apiURL := fmt.Sprintf("https://api.alloha.tv/?token=%s&kp=%s", url.QueryEscape(h.config.AllohaToken), url.QueryEscape(kpID)) + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Get(apiURL) + if err != nil { + http.Error(w, "Failed to fetch from Alloha API", http.StatusBadGateway) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + http.Error(w, fmt.Sprintf("Alloha API error: %d", resp.StatusCode), http.StatusBadGateway) + return + } + body, err := io.ReadAll(resp.Body) + if err != nil { + http.Error(w, "Failed to read Alloha response", http.StatusBadGateway) + return + } + + // Define only the parts we need + var raw struct { + Status string `json:"status"` + Data struct { + Seasons []struct { + Key string `json:"key"` + Value struct { + Episodes []struct { + Key string `json:"key"` + Value struct { + Translation []struct { + Value struct { + Translation string `json:"translation"` + } `json:"value"` + } `json:"translation"` + } `json:"value"` + } `json:"episodes"` + } `json:"value"` + } `json:"seasons"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &raw); err != nil { + http.Error(w, "Invalid JSON from Alloha", http.StatusBadGateway) + return + } + + type episodeMeta struct { + Episode int `json:"episode"` + Translations []string `json:"translations"` + } + type seasonMeta struct { + Season int `json:"season"` + Episodes []episodeMeta `json:"episodes"` + } + out := struct { + Success bool `json:"success"` + Seasons []seasonMeta `json:"seasons"` + }{Success: true} + + for _, s := range raw.Data.Seasons { + seasonNum, _ := strconv.Atoi(strings.TrimSpace(s.Key)) + sm := seasonMeta{Season: seasonNum} + for _, e := range s.Value.Episodes { + epNum, _ := strconv.Atoi(strings.TrimSpace(e.Key)) + em := episodeMeta{Episode: epNum} + for _, tr := range e.Value.Translation { + t := strings.TrimSpace(tr.Value.Translation) + if t != "" { + em.Translations = append(em.Translations, t) + } + } + sm.Episodes = append(sm.Episodes, em) + } + out.Seasons = append(out.Seasons, sm) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} + func (h *PlayersHandler) GetLumexPlayer(w http.ResponseWriter, r *http.Request) { log.Printf("GetLumexPlayer called: %s %s", r.Method, r.URL.Path) @@ -601,7 +694,8 @@ func (h *PlayersHandler) GetHDVBPlayer(w http.ResponseWriter, r *http.Request) { } log.Printf("HDVB API URL: %s", apiURL) - resp, err := http.Get(apiURL) + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Get(apiURL) if err != nil { log.Printf("Error fetching HDVB data: %v", err) http.Error(w, "Failed to fetch player data", http.StatusInternalServerError) diff --git a/pkg/services/tmdb.go b/pkg/services/tmdb.go index b1502fd..56017f8 100644 --- a/pkg/services/tmdb.go +++ b/pkg/services/tmdb.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strconv" + "time" "neomovies-api/pkg/models" ) @@ -17,11 +18,16 @@ type TMDBService struct { } func NewTMDBService(accessToken string) *TMDBService { - return &TMDBService{ - accessToken: accessToken, - baseURL: "https://api.themoviedb.org/3", - client: &http.Client{}, - } + transport := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 60 * time.Second, + } + return &TMDBService{ + accessToken: accessToken, + baseURL: "https://api.themoviedb.org/3", + client: &http.Client{Timeout: 10 * time.Second, Transport: transport}, + } } func (s *TMDBService) makeRequest(endpoint string, target interface{}) error { @@ -194,8 +200,10 @@ func (s *TMDBService) FindTMDBIdByIMDB(imdbID string, media string, language str endpoint := fmt.Sprintf("%s/find/%s?%s", s.baseURL, url.PathEscape(imdbID), params.Encode()) var resp struct { - MovieResults []struct{ ID int `json:"id"` } `json:"movie_results"` - TVResults []struct{ ID int `json:"id"` } `json:"tv_results"` + MovieResults []struct{ ID int `json:"id"` } `json:"movie_results"` + TVResults []struct{ ID int `json:"id"` } `json:"tv_results"` + TVEpisodeResults []struct{ ShowID int `json:"show_id"` } `json:"tv_episode_results"` + TVSeasonResults []struct{ ShowID int `json:"show_id"` } `json:"tv_season_results"` } if err := s.makeRequest(endpoint, &resp); err != nil { return 0, err @@ -217,6 +225,12 @@ func (s *TMDBService) FindTMDBIdByIMDB(imdbID string, media string, language str if len(resp.TVResults) > 0 { return resp.TVResults[0].ID, nil } + if len(resp.TVSeasonResults) > 0 { + return resp.TVSeasonResults[0].ShowID, nil + } + if len(resp.TVEpisodeResults) > 0 { + return resp.TVEpisodeResults[0].ShowID, nil + } } return 0, fmt.Errorf("tmdb id not found for imdb %s", imdbID) }