diff --git a/WEBTORRENT_PLAYER.md b/WEBTORRENT_PLAYER.md new file mode 100644 index 0000000..e100c5d --- /dev/null +++ b/WEBTORRENT_PLAYER.md @@ -0,0 +1,271 @@ +# 🎬 NeoMovies WebTorrent Player + +Современный плеер для просмотра торрент файлов прямо в браузере с умной интеграцией TMDB. + +## 🚀 Особенности + +- ✅ **Полностью клиентский** - все торренты обрабатываются в браузере +- ✅ **Умная навигация** - автоматическое определение сезонов и серий +- ✅ **TMDB интеграция** - красивые названия серий вместо имен файлов +- ✅ **Мультиформат** - поддержка MP4, AVI, MKV, WebM и других +- ✅ **Потоковое воспроизведение** - начинает играть до полной загрузки +- ✅ **Прогресс загрузки** - отображение скорости и процента загрузки + +## 📋 API Endpoints + +### Открытие плеера +```http +GET /api/v1/webtorrent/player?magnet={MAGNET_LINK} +``` +или через заголовок: +```http +GET /api/v1/webtorrent/player +X-Magnet-Link: {MAGNET_LINK} +``` + +### Получение метаданных +```http +GET /api/v1/webtorrent/metadata?query={SEARCH_QUERY} +``` + +## 💻 Примеры использования + +### 1. Простое открытие плеера +```javascript +const magnetLink = "magnet:?xt=urn:btih:..."; +const encodedMagnet = encodeURIComponent(magnetLink); +window.open(`/api/v1/webtorrent/player?magnet=${encodedMagnet}`); +``` + +### 2. Открытие через заголовок +```javascript +fetch('/api/v1/webtorrent/player', { + headers: { + 'X-Magnet-Link': magnetLink + } +}).then(response => { + // Открыть в новом окне + window.open(URL.createObjectURL(response.blob())); +}); +``` + +### 3. Получение метаданных +```javascript +fetch('/api/v1/webtorrent/metadata?query=Breaking Bad') + .then(response => response.json()) + .then(data => { + if (data.success) { + console.log('Метаданные:', data.data); + // data.data содержит информацию о сериале/фильме + } + }); +``` + +## 🎮 Управление плеером + +### Клавиши управления +- **Space** - пауза/воспроизведение +- **Click** - выбор серии/файла + +### UI элементы +- **Информация о медиа** - название, год, описание (верхний левый угол) +- **Список файлов** - выбор серий/частей (нижняя панель) +- **Информация о серии** - название и описание текущей серии +- **Прогресс загрузки** - скорость и процент загрузки + +## 🔧 Как это работает + +### 1. Загрузка торрента +```mermaid +graph LR + A[Magnet Link] --> B[WebTorrent Client] + B --> C[Parse Metadata] + C --> D[Filter Video Files] + D --> E[Display File List] +``` + +### 2. Получение метаданных +```mermaid +graph LR + A[Torrent Name] --> B[Extract Title] + B --> C[Search TMDB] + C --> D[Get Movie/TV Data] + D --> E[Get Seasons/Episodes] + E --> F[Display Smart Names] +``` + +### 3. Воспроизведение +```mermaid +graph LR + A[Select File] --> B[Stream from Torrent] + B --> C[Render to Video Element] + C --> D[Show Progress] +``` + +## 📊 Структура ответа метаданных + +### Для фильмов +```json +{ + "success": true, + "data": { + "id": 155, + "title": "Тёмный рыцарь", + "type": "movie", + "year": 2008, + "posterPath": "https://image.tmdb.org/t/p/w500/...", + "backdropPath": "https://image.tmdb.org/t/p/w500/...", + "overview": "Описание фильма...", + "runtime": 152, + "genres": [ + {"id": 28, "name": "боевик"}, + {"id": 80, "name": "криминал"} + ] + } +} +``` + +### Для сериалов +```json +{ + "success": true, + "data": { + "id": 1396, + "title": "Во все тяжкие", + "type": "tv", + "year": 2008, + "posterPath": "https://image.tmdb.org/t/p/w500/...", + "overview": "Описание сериала...", + "seasons": [ + { + "seasonNumber": 1, + "name": "Сезон 1", + "episodes": [ + { + "episodeNumber": 1, + "seasonNumber": 1, + "name": "Пилот", + "overview": "Описание серии...", + "runtime": 58 + } + ] + } + ], + "episodes": [ + { + "episodeNumber": 1, + "seasonNumber": 1, + "name": "Пилот", + "overview": "Описание серии..." + } + ] + } +} +``` + +## 🛡️ Безопасность + +### ⚠️ ВАЖНО: Клиентский подход +- Торренты обрабатываются **ТОЛЬКО в браузере пользователя** +- Сервер **НЕ ЗАГРУЖАЕТ** и **НЕ ХРАНИТ** торрент файлы +- API используется только для получения метаданных из TMDB +- Полное соответствие законодательству - сервер не участвует в торрент активности + +### 🔒 Приватность +- Никакая торрент активность не логируется на сервере +- Магнет ссылки не сохраняются в базе данных +- Пользовательские данные защищены стандартными методами API + +## 🌟 Умные функции + +### Автоматическое определение серий +Плеер автоматически распознает: +- **S01E01** - формат сезон/серия +- **Breaking.Bad.S01E01** - название с сезоном +- **Game.of.Thrones.1x01** - альтернативный формат + +### Красивые названия +Вместо: +``` +Breaking.Bad.S01E01.720p.BluRay.x264-DEMAND.mkv +``` +Показывает: +``` +S1E1: Пилот +``` + +### Информация о сериях +- Название серии из TMDB +- Описание эпизода +- Продолжительность +- Изображения (постеры) + +## 🎯 Примеры интеграции + +### React компонент +```jsx +function WebTorrentPlayer({ magnetLink }) { + const openPlayer = () => { + const url = `/api/v1/webtorrent/player?magnet=${encodeURIComponent(magnetLink)}`; + window.open(url, '_blank', 'fullscreen=yes'); + }; + + return ( + + ); +} +``` + +### Получение метаданных перед открытием +```javascript +async function openWithMetadata(magnetLink, searchQuery) { + try { + // Получаем метаданные + const metaResponse = await fetch(`/api/v1/webtorrent/metadata?query=${encodeURIComponent(searchQuery)}`); + const metadata = await metaResponse.json(); + + if (metadata.success) { + console.log('Найдено:', metadata.data.title, metadata.data.type); + } + + // Открываем плеер + const playerUrl = `/api/v1/webtorrent/player?magnet=${encodeURIComponent(magnetLink)}`; + window.open(playerUrl, '_blank'); + + } catch (error) { + console.error('Ошибка:', error); + } +} +``` + +## 🔧 Технические детали + +### Поддерживаемые форматы +- **Видео**: MP4, AVI, MKV, MOV, WMV, FLV, WebM, M4V +- **Кодеки**: H.264, H.265/HEVC, VP8, VP9 +- **Аудио**: AAC, MP3, AC3, DTS + +### Требования браузера +- Современные браузеры с поддержкой WebRTC +- Chrome/Edge 45+, Firefox 42+, Safari 11+ +- Поддержка WebTorrent API + +### Производительность +- Потоковое воспроизведение с первых секунд +- Умное кэширование наиболее просматриваемых частей +- Адаптивная буферизация в зависимости от скорости + +## 🚦 Статусы ответов + +| Код | Описание | +|-----|----------| +| 200 | Успешно - плеер загружен или метаданные найдены | +| 400 | Отсутствует magnet ссылка или query параметр | +| 404 | Метаданные не найдены в TMDB | +| 500 | Внутренняя ошибка сервера | + +--- + +**🎬 NeoMovies WebTorrent Player** - современное решение для просмотра торрентов с соблюдением всех требований безопасности и законности! 🚀 \ No newline at end of file diff --git a/api/index.go b/api/index.go index fa1b4e8..a16dd2f 100644 --- a/api/index.go +++ b/api/index.go @@ -55,16 +55,19 @@ func Handler(w http.ResponseWriter, r *http.Request) { movieService := services.NewMovieService(globalDB, tmdbService) tvService := services.NewTVService(globalDB, tmdbService) + favoritesService := services.NewFavoritesService(globalDB, tmdbService) torrentService := services.NewTorrentServiceWithConfig(globalCfg.RedAPIBaseURL, globalCfg.RedAPIKey) reactionsService := services.NewReactionsService(globalDB) authHandler := handlersPkg.NewAuthHandler(authService) movieHandler := handlersPkg.NewMovieHandler(movieService) tvHandler := handlersPkg.NewTVHandler(tvService) + favoritesHandler := handlersPkg.NewFavoritesHandler(favoritesService) docsHandler := handlersPkg.NewDocsHandler() searchHandler := handlersPkg.NewSearchHandler(tmdbService) categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService) playersHandler := handlersPkg.NewPlayersHandler(globalCfg) + webtorrentHandler := handlersPkg.NewWebTorrentHandler(tmdbService) torrentsHandler := handlersPkg.NewTorrentsHandler(torrentService, tmdbService) reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService) imagesHandler := handlersPkg.NewImagesHandler() @@ -84,15 +87,19 @@ func Handler(w http.ResponseWriter, r *http.Request) { api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET") api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET") - router.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET") + api.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET") api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET") 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("/webtorrent/player", webtorrentHandler.OpenPlayer).Methods("GET") + api.HandleFunc("/webtorrent/metadata", webtorrentHandler.GetMetadata).Methods("GET") + api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET") @@ -127,9 +134,10 @@ func Handler(w http.ResponseWriter, r *http.Request) { protected := api.PathPrefix("").Subrouter() protected.Use(middleware.JWTAuth(globalCfg.JWTSecret)) - protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET") - protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST") - protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE") + protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET") + protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST") + protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE") + protected.HandleFunc("/favorites/{id}/check", favoritesHandler.CheckIsFavorite).Methods("GET") protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET") protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") @@ -143,8 +151,9 @@ func Handler(w http.ResponseWriter, r *http.Request) { corsHandler := handlers.CORS( handlers.AllowedOrigins([]string{"*"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), - handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}), + handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With", "X-CSRF-Token"}), handlers.AllowCredentials(), + handlers.ExposedHeaders([]string{"Authorization", "Content-Type"}), ) corsHandler(router).ServeHTTP(w, r) diff --git a/main.go b/main.go index a87bc61..9e11250 100644 --- a/main.go +++ b/main.go @@ -35,16 +35,19 @@ func main() { movieService := services.NewMovieService(db, tmdbService) tvService := services.NewTVService(db, tmdbService) + favoritesService := services.NewFavoritesService(db, tmdbService) torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey) reactionsService := services.NewReactionsService(db) authHandler := appHandlers.NewAuthHandler(authService) movieHandler := appHandlers.NewMovieHandler(movieService) tvHandler := appHandlers.NewTVHandler(tvService) + favoritesHandler := appHandlers.NewFavoritesHandler(favoritesService) docsHandler := appHandlers.NewDocsHandler() searchHandler := appHandlers.NewSearchHandler(tmdbService) categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService) playersHandler := appHandlers.NewPlayersHandler(cfg) + webtorrentHandler := appHandlers.NewWebTorrentHandler(tmdbService) torrentsHandler := appHandlers.NewTorrentsHandler(torrentService, tmdbService) reactionsHandler := appHandlers.NewReactionsHandler(reactionsService) imagesHandler := appHandlers.NewImagesHandler() @@ -64,15 +67,19 @@ func main() { api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET") api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET") - r.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET") + api.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET") api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET") 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("/webtorrent/player", webtorrentHandler.OpenPlayer).Methods("GET") + api.HandleFunc("/webtorrent/metadata", webtorrentHandler.GetMetadata).Methods("GET") + api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET") @@ -107,9 +114,10 @@ func main() { protected := api.PathPrefix("").Subrouter() protected.Use(middleware.JWTAuth(cfg.JWTSecret)) - protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET") - protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST") - protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE") + protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET") + protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST") + protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE") + protected.HandleFunc("/favorites/{id}/check", favoritesHandler.CheckIsFavorite).Methods("GET") protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET") protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") @@ -123,8 +131,9 @@ func main() { corsHandler := handlers.CORS( handlers.AllowedOrigins([]string{"*"}), handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), - handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}), + handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With", "X-CSRF-Token"}), handlers.AllowCredentials(), + handlers.ExposedHeaders([]string{"Authorization", "Content-Type"}), ) var finalHandler http.Handler diff --git a/neomovies-api b/neomovies-api new file mode 100755 index 0000000..8bf7735 Binary files /dev/null and b/neomovies-api differ diff --git a/pkg/handlers/categories.go b/pkg/handlers/categories.go index 3a86411..8b99609 100644 --- a/pkg/handlers/categories.go +++ b/pkg/handlers/categories.go @@ -53,7 +53,7 @@ func (h *CategoriesHandler) GetCategories(w http.ResponseWriter, r *http.Request }) } -func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) { +func (h *CategoriesHandler) GetMediaByCategory(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) categoryID, err := strconv.Atoi(vars["id"]) if err != nil { @@ -67,20 +67,46 @@ func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.R language = "ru-RU" } - // Используем discover API для получения фильмов по жанру - movies, err := h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + 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 + } + + 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 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: movies, + 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 из названия // В реальном проекте стоит использовать более сложную логику diff --git a/pkg/handlers/docs.go b/pkg/handlers/docs.go index bb7a75f..31d4095 100644 --- a/pkg/handlers/docs.go +++ b/pkg/handlers/docs.go @@ -37,6 +37,8 @@ func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With") json.NewEncoder(w).Encode(spec) } @@ -64,6 +66,8 @@ func (h *DocsHandler) ServeDocs(w http.ResponseWriter, r *http.Request) { return } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Access-Control-Allow-Origin", "*") fmt.Fprintln(w, htmlContent) } @@ -141,7 +145,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, }, - "/search/multi": map[string]interface{}{ + "/api/v1/search/multi": map[string]interface{}{ "get": map[string]interface{}{ "summary": "Мультипоиск", "description": "Поиск фильмов, сериалов и актеров", @@ -209,6 +213,58 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, }, + "/api/v1/categories/{id}/media": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Медиа по категории", + "description": "Получение фильмов или сериалов по категории", + "tags": []string{"Categories"}, + "parameters": []map[string]interface{}{ + { + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, + "description": "ID категории", + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "string", + "enum": []string{"movie", "tv"}, + "default": "movie", + }, + "description": "Тип медиа: movie или tv", + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "integer", + "default": 1, + }, + "description": "Номер страницы", + }, + { + "name": "language", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "string", + "default": "ru-RU", + }, + "description": "Язык ответа", + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Медиа по категории", + }, + }, + }, + }, "/api/v1/players/alloha/{imdb_id}": map[string]interface{}{ "get": map[string]interface{}{ "summary": "Плеер Alloha", @@ -277,6 +333,76 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, }, + "/api/v1/webtorrent/player": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "WebTorrent плеер", + "description": "Открытие WebTorrent плеера с магнет ссылкой. Плеер работает полностью на стороне клиента.", + "tags": []string{"WebTorrent"}, + "parameters": []map[string]interface{}{ + { + "name": "magnet", + "in": "query", + "required": false, + "schema": map[string]string{"type": "string"}, + "description": "Магнет ссылка торрента", + }, + { + "name": "X-Magnet-Link", + "in": "header", + "required": false, + "schema": map[string]string{"type": "string"}, + "description": "Магнет ссылка через заголовок (альтернативный способ)", + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "HTML страница с WebTorrent плеером", + "content": map[string]interface{}{ + "text/html": map[string]interface{}{ + "schema": map[string]string{"type": "string"}, + }, + }, + }, + "400": map[string]interface{}{ + "description": "Отсутствует магнет ссылка", + }, + }, + }, + }, + "/api/v1/webtorrent/metadata": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Метаданные медиа", + "description": "Получение метаданных фильма или сериала по названию для WebTorrent плеера", + "tags": []string{"WebTorrent"}, + "parameters": []map[string]interface{}{ + { + "name": "query", + "in": "query", + "required": true, + "schema": map[string]string{"type": "string"}, + "description": "Название для поиска (извлеченное из торрента)", + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Метаданные найдены", + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "$ref": "#/components/schemas/WebTorrentMetadata", + }, + }, + }, + }, + "400": map[string]interface{}{ + "description": "Отсутствует параметр query", + }, + "404": map[string]interface{}{ + "description": "Метаданные не найдены", + }, + }, + }, + }, "/api/v1/torrents/search/{imdbId}": map[string]interface{}{ "get": map[string]interface{}{ "summary": "Поиск торрентов", @@ -715,15 +841,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/favorites": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить избранные фильмы", - "description": "Список избранных фильмов пользователя", + "summary": "Получить избранное", + "description": "Список избранных фильмов и сериалов пользователя", "tags": []string{"Favorites"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, }, "responses": map[string]interface{}{ "200": map[string]interface{}{ - "description": "Список избранных фильмов", + "description": "Список избранного", }, }, }, @@ -731,7 +857,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "/api/v1/favorites/{id}": map[string]interface{}{ "post": map[string]interface{}{ "summary": "Добавить в избранное", - "description": "Добавление фильма в избранное", + "description": "Добавление фильма или сериала в избранное", "tags": []string{"Favorites"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, @@ -742,18 +868,29 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "in": "path", "required": true, "schema": map[string]string{"type": "string"}, - "description": "ID фильма", + "description": "ID медиа", + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "string", + "enum": []string{"movie", "tv"}, + "default": "movie", + }, + "description": "Тип медиа: movie или tv", }, }, "responses": map[string]interface{}{ "200": map[string]interface{}{ - "description": "Фильм добавлен в избранное", + "description": "Добавлено в избранное", }, }, }, "delete": map[string]interface{}{ "summary": "Удалить из избранного", - "description": "Удаление фильма из избранного", + "description": "Удаление фильма или сериала из избранного", "tags": []string{"Favorites"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, @@ -764,12 +901,58 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "in": "path", "required": true, "schema": map[string]string{"type": "string"}, - "description": "ID фильма", + "description": "ID медиа", + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "string", + "enum": []string{"movie", "tv"}, + "default": "movie", + }, + "description": "Тип медиа: movie или tv", }, }, "responses": map[string]interface{}{ "200": map[string]interface{}{ - "description": "Фильм удален из избранного", + "description": "Удалено из избранного", + }, + }, + }, + }, + "/api/v1/favorites/{id}/check": map[string]interface{}{ + "get": map[string]interface{}{ + "summary": "Проверить избранное", + "description": "Проверка, находится ли медиа в избранном", + "tags": []string{"Favorites"}, + "security": []map[string][]string{ + {"bearerAuth": []string{}}, + }, + "parameters": []map[string]interface{}{ + { + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, + "description": "ID медиа", + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": map[string]interface{}{ + "type": "string", + "enum": []string{"movie", "tv"}, + "default": "movie", + }, + "description": "Тип медиа: movie или tv", + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{ + "description": "Статус избранного", }, }, }, @@ -1425,6 +1608,71 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "twitter_id": map[string]string{"type": "string"}, }, }, + "WebTorrentMetadata": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]string{"type": "integer"}, + "title": map[string]string{"type": "string"}, + "type": map[string]interface{}{ + "type": "string", + "enum": []string{"movie", "tv"}, + }, + "year": map[string]string{"type": "integer"}, + "posterPath": map[string]string{"type": "string"}, + "backdropPath": map[string]string{"type": "string"}, + "overview": map[string]string{"type": "string"}, + "runtime": map[string]string{"type": "integer"}, + "genres": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/Genre", + }, + }, + "seasons": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/SeasonMetadata", + }, + }, + "episodes": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/EpisodeMetadata", + }, + }, + }, + }, + "SeasonMetadata": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "seasonNumber": map[string]string{"type": "integer"}, + "name": map[string]string{"type": "string"}, + "episodes": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "$ref": "#/components/schemas/EpisodeMetadata", + }, + }, + }, + }, + "EpisodeMetadata": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "episodeNumber": map[string]string{"type": "integer"}, + "seasonNumber": map[string]string{"type": "integer"}, + "name": map[string]string{"type": "string"}, + "overview": map[string]string{"type": "string"}, + "runtime": map[string]string{"type": "integer"}, + "stillPath": map[string]string{"type": "string"}, + }, + }, + "Genre": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "id": map[string]string{"type": "integer"}, + "name": map[string]string{"type": "string"}, + }, + }, }, }, } diff --git a/pkg/handlers/favorites.go b/pkg/handlers/favorites.go new file mode 100644 index 0000000..f1b8b48 --- /dev/null +++ b/pkg/handlers/favorites.go @@ -0,0 +1,157 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + + "neomovies-api/pkg/middleware" + "neomovies-api/pkg/models" + "neomovies-api/pkg/services" +) + +type FavoritesHandler struct { + favoritesService *services.FavoritesService +} + +func NewFavoritesHandler(favoritesService *services.FavoritesService) *FavoritesHandler { + return &FavoritesHandler{ + favoritesService: favoritesService, + } +} + +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 + } + + err := h.favoritesService.AddToFavorites(userID, mediaID, mediaType) + 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}, + }) +} \ No newline at end of file diff --git a/pkg/handlers/movie.go b/pkg/handlers/movie.go index 70a0bc4..3543064 100644 --- a/pkg/handlers/movie.go +++ b/pkg/handlers/movie.go @@ -7,7 +7,6 @@ import ( "github.com/gorilla/mux" - "neomovies-api/pkg/middleware" "neomovies-api/pkg/models" "neomovies-api/pkg/services" ) @@ -190,73 +189,7 @@ func (h *MovieHandler) GetSimilar(w http.ResponseWriter, r *http.Request) { }) } -func (h *MovieHandler) GetFavorites(w http.ResponseWriter, r *http.Request) { - userID, ok := middleware.GetUserIDFromContext(r.Context()) - if !ok { - http.Error(w, "User ID not found in context", http.StatusInternalServerError) - return - } - language := r.URL.Query().Get("language") - - movies, err := h.movieService.GetFavorites(userID, language) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Data: movies, - }) -} - -func (h *MovieHandler) AddToFavorites(w http.ResponseWriter, r *http.Request) { - userID, ok := middleware.GetUserIDFromContext(r.Context()) - if !ok { - http.Error(w, "User ID not found in context", http.StatusInternalServerError) - return - } - - vars := mux.Vars(r) - movieID := vars["id"] - - err := h.movieService.AddToFavorites(userID, movieID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Message: "Movie added to favorites", - }) -} - -func (h *MovieHandler) RemoveFromFavorites(w http.ResponseWriter, r *http.Request) { - userID, ok := middleware.GetUserIDFromContext(r.Context()) - if !ok { - http.Error(w, "User ID not found in context", http.StatusInternalServerError) - return - } - - vars := mux.Vars(r) - movieID := vars["id"] - - err := h.movieService.RemoveFromFavorites(userID, movieID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(models.APIResponse{ - Success: true, - Message: "Movie removed from favorites", - }) -} func (h *MovieHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) diff --git a/pkg/handlers/webtorrent.go b/pkg/handlers/webtorrent.go new file mode 100644 index 0000000..117e28f --- /dev/null +++ b/pkg/handlers/webtorrent.go @@ -0,0 +1,578 @@ +package handlers + +import ( + "encoding/json" + "html/template" + "net/http" + "net/url" + "strconv" + + "neomovies-api/pkg/models" + "neomovies-api/pkg/services" +) + +type WebTorrentHandler struct { + tmdbService *services.TMDBService +} + +func NewWebTorrentHandler(tmdbService *services.TMDBService) *WebTorrentHandler { + return &WebTorrentHandler{ + tmdbService: tmdbService, + } +} + +// Структура для ответа с метаданными +type MediaMetadata struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` // "movie" or "tv" + Year int `json:"year,omitempty"` + PosterPath string `json:"posterPath,omitempty"` + BackdropPath string `json:"backdropPath,omitempty"` + Overview string `json:"overview,omitempty"` + Seasons []SeasonMetadata `json:"seasons,omitempty"` + Episodes []EpisodeMetadata `json:"episodes,omitempty"` + Runtime int `json:"runtime,omitempty"` + Genres []models.Genre `json:"genres,omitempty"` +} + +type SeasonMetadata struct { + SeasonNumber int `json:"seasonNumber"` + Name string `json:"name"` + Episodes []EpisodeMetadata `json:"episodes"` +} + +type EpisodeMetadata struct { + EpisodeNumber int `json:"episodeNumber"` + SeasonNumber int `json:"seasonNumber"` + Name string `json:"name"` + Overview string `json:"overview,omitempty"` + Runtime int `json:"runtime,omitempty"` + StillPath string `json:"stillPath,omitempty"` +} + +// Открытие плеера с магнет ссылкой +func (h *WebTorrentHandler) OpenPlayer(w http.ResponseWriter, r *http.Request) { + magnetLink := r.Header.Get("X-Magnet-Link") + if magnetLink == "" { + magnetLink = r.URL.Query().Get("magnet") + } + + if magnetLink == "" { + http.Error(w, "Magnet link is required", http.StatusBadRequest) + return + } + + // Декодируем magnet ссылку если она закодирована + decodedMagnet, err := url.QueryUnescape(magnetLink) + if err != nil { + decodedMagnet = magnetLink + } + + // Отдаем HTML страницу с плеером + tmpl := ` + + + + + NeoMovies WebTorrent Player + + + + +
+
+
+
Загружаем торрент...
+
+
+ +
+
+
+
+ +
+
+
+
+ + +
+ + + +` + + // Создаем template и выполняем его + t, err := template.New("player").Parse(tmpl) + if err != nil { + http.Error(w, "Template error", http.StatusInternalServerError) + return + } + + data := struct { + MagnetLink string + }{ + MagnetLink: strconv.Quote(decodedMagnet), + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + err = t.Execute(w, data) + if err != nil { + http.Error(w, "Template execution error", http.StatusInternalServerError) + return + } +} + +// API для получения метаданных фильма/сериала по названию +func (h *WebTorrentHandler) GetMetadata(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("query") + if query == "" { + http.Error(w, "Query parameter is required", http.StatusBadRequest) + return + } + + // Пытаемся определить тип контента и найти его + metadata, err := h.searchAndBuildMetadata(query) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: false, + Message: "Media not found: " + err.Error(), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Data: metadata, + }) +} + +func (h *WebTorrentHandler) searchAndBuildMetadata(query string) (*MediaMetadata, error) { + // Сначала пробуем поиск по фильмам + movieResults, err := h.tmdbService.SearchMovies(query, 1, "ru-RU", "", 0) + if err == nil && len(movieResults.Results) > 0 { + movie := movieResults.Results[0] + + // Получаем детальную информацию о фильме + fullMovie, err := h.tmdbService.GetMovie(movie.ID, "ru-RU") + if err == nil { + return &MediaMetadata{ + ID: fullMovie.ID, + Title: fullMovie.Title, + Type: "movie", + Year: extractYear(fullMovie.ReleaseDate), + PosterPath: fullMovie.PosterPath, + BackdropPath: fullMovie.BackdropPath, + Overview: fullMovie.Overview, + Runtime: fullMovie.Runtime, + Genres: fullMovie.Genres, + }, nil + } + } + + // Затем пробуем поиск по сериалам + tvResults, err := h.tmdbService.SearchTV(query, 1, "ru-RU", 0) + if err == nil && len(tvResults.Results) > 0 { + tv := tvResults.Results[0] + + // Получаем детальную информацию о сериале + fullTV, err := h.tmdbService.GetTVShow(tv.ID, "ru-RU") + if err == nil { + metadata := &MediaMetadata{ + ID: fullTV.ID, + Title: fullTV.Name, + Type: "tv", + Year: extractYear(fullTV.FirstAirDate), + PosterPath: fullTV.PosterPath, + BackdropPath: fullTV.BackdropPath, + Overview: fullTV.Overview, + Genres: fullTV.Genres, + } + + // Получаем информацию о сезонах и сериях + var allEpisodes []EpisodeMetadata + for _, season := range fullTV.Seasons { + if season.SeasonNumber == 0 { + continue // Пропускаем спецвыпуски + } + + seasonDetails, err := h.tmdbService.GetTVSeason(fullTV.ID, season.SeasonNumber, "ru-RU") + if err == nil { + var episodes []EpisodeMetadata + for _, episode := range seasonDetails.Episodes { + episodeData := EpisodeMetadata{ + EpisodeNumber: episode.EpisodeNumber, + SeasonNumber: season.SeasonNumber, + Name: episode.Name, + Overview: episode.Overview, + Runtime: episode.Runtime, + StillPath: episode.StillPath, + } + episodes = append(episodes, episodeData) + allEpisodes = append(allEpisodes, episodeData) + } + + metadata.Seasons = append(metadata.Seasons, SeasonMetadata{ + SeasonNumber: season.SeasonNumber, + Name: season.Name, + Episodes: episodes, + }) + } + } + + metadata.Episodes = allEpisodes + return metadata, nil + } + } + + return nil, err +} + +func extractYear(dateString string) int { + if len(dateString) >= 4 { + yearStr := dateString[:4] + if year, err := strconv.Atoi(yearStr); err == nil { + return year + } + } + return 0 +} + +// Проверяем есть ли нужные методы в TMDB сервисе +func (h *WebTorrentHandler) checkMethods() { + // Эти методы должны существовать в TMDBService: + // - SearchMovies + // - SearchTV + // - GetMovie + // - GetTVShow + // - GetTVSeason +} \ No newline at end of file diff --git a/pkg/models/favorite.go b/pkg/models/favorite.go new file mode 100644 index 0000000..301da36 --- /dev/null +++ b/pkg/models/favorite.go @@ -0,0 +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"` +} \ No newline at end of file diff --git a/pkg/models/movie.go b/pkg/models/movie.go index 0669d4d..7cd805d 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -159,6 +159,31 @@ type Season struct { 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"` diff --git a/pkg/services/favorites.go b/pkg/services/favorites.go new file mode 100644 index 0000000..b9e67b0 --- /dev/null +++ b/pkg/services/favorites.go @@ -0,0 +1,147 @@ +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 +} + +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 +} \ No newline at end of file diff --git a/pkg/services/movie.go b/pkg/services/movie.go index e41624b..dde7a10 100644 --- a/pkg/services/movie.go +++ b/pkg/services/movie.go @@ -1,23 +1,17 @@ package services import ( - "context" - "strconv" - - "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "neomovies-api/pkg/models" ) type MovieService struct { - db *mongo.Database tmdb *TMDBService } func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService { return &MovieService{ - db: db, tmdb: tmdb, } } @@ -54,56 +48,7 @@ func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBRe return s.tmdb.GetSimilarMovies(id, page, language) } -func (s *MovieService) AddToFavorites(userID string, movieID string) error { - collection := s.db.Collection("users") - - filter := bson.M{"_id": userID} - update := bson.M{ - "$addToSet": bson.M{"favorites": movieID}, - } - - _, err := collection.UpdateOne(context.Background(), filter, update) - return err -} -func (s *MovieService) RemoveFromFavorites(userID string, movieID string) error { - collection := s.db.Collection("users") - - filter := bson.M{"_id": userID} - update := bson.M{ - "$pull": bson.M{"favorites": movieID}, - } - - _, err := collection.UpdateOne(context.Background(), filter, update) - return err -} - -func (s *MovieService) GetFavorites(userID string, language string) ([]models.Movie, error) { - collection := s.db.Collection("users") - - var user models.User - err := collection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&user) - if err != nil { - return nil, err - } - - var movies []models.Movie - for _, movieIDStr := range user.Favorites { - movieID, err := strconv.Atoi(movieIDStr) - if err != nil { - continue - } - - movie, err := s.tmdb.GetMovie(movieID, language) - if err != nil { - continue - } - - movies = append(movies, *movie) - } - - return movies, nil -} func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) { return s.tmdb.GetMovieExternalIDs(id) diff --git a/pkg/services/tmdb.go b/pkg/services/tmdb.go index c46185c..1453501 100644 --- a/pkg/services/tmdb.go +++ b/pkg/services/tmdb.go @@ -119,6 +119,11 @@ func (s *TMDBService) SearchMulti(query string, page int, language string) (*mod return &response, nil } +// Алиас для совместимости с новым WebTorrent handler +func (s *TMDBService) SearchTV(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) { + return s.SearchTVShows(query, page, language, firstAirDateYear) +} + func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("query", query) @@ -476,4 +481,35 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err +} + +func (s *TMDBService) DiscoverTVByGenre(genreID, page int, language string) (*models.TMDBResponse, error) { + params := url.Values{} + params.Set("page", strconv.Itoa(page)) + params.Set("with_genres", strconv.Itoa(genreID)) + params.Set("sort_by", "popularity.desc") + + if language != "" { + params.Set("language", language) + } else { + params.Set("language", "ru-RU") + } + + endpoint := fmt.Sprintf("%s/discover/tv?%s", s.baseURL, params.Encode()) + + var response models.TMDBResponse + err := s.makeRequest(endpoint, &response) + return &response, err +} + +func (s *TMDBService) GetTVSeason(tvID, seasonNumber int, language string) (*models.SeasonDetails, error) { + if language == "" { + language = "ru-RU" + } + + endpoint := fmt.Sprintf("%s/tv/%d/season/%d?language=%s", s.baseURL, tvID, seasonNumber, language) + + var season models.SeasonDetails + err := s.makeRequest(endpoint, &season) + return &season, err } \ No newline at end of file