From ea3159fb8ef6586d2f8844658c992c1df0f4689a Mon Sep 17 00:00:00 2001 From: "factory-droid[bot]" <138933559+factory-droid[bot]@users.noreply.github.com> Date: Sun, 28 Sep 2025 11:37:56 +0000 Subject: [PATCH] feat: implement JWT refresh token mechanism and improve auth - Add refresh token support with 30-day expiry - Implement automatic token rotation on refresh - Add new endpoints: /auth/refresh, /auth/revoke-token, /auth/revoke-all-tokens - Reduce access token lifetime to 1 hour for better security - Store refresh tokens in user document with metadata - Add support for token cleanup and management - Update login flow to return both access and refresh tokens - Maintain backward compatibility with existing auth methods --- main.go | 3 + pkg/config/vars.go | 42 +- pkg/database/connection.go | 2 +- pkg/handlers/auth.go | 92 ++++- pkg/handlers/auth_helpers.go | 2 +- pkg/handlers/categories.go | 2 +- pkg/handlers/docs.go | 716 +++++++++++++++++------------------ pkg/handlers/health.go | 2 +- pkg/handlers/images.go | 2 +- pkg/handlers/movie.go | 8 +- pkg/handlers/players.go | 80 ++-- pkg/handlers/reactions.go | 6 +- pkg/handlers/search.go | 2 +- pkg/handlers/torrents.go | 6 +- pkg/handlers/tv.go | 2 +- pkg/middleware/auth.go | 2 +- pkg/models/favorite.go | 2 +- pkg/models/user.go | 55 ++- pkg/monitor/monitor.go | 14 +- pkg/services/auth.go | 282 +++++++++++--- pkg/services/email.go | 4 +- pkg/services/movie.go | 4 +- pkg/services/reactions.go | 10 +- pkg/services/tmdb.go | 102 ++--- pkg/services/torrent.go | 3 +- pkg/services/tv.go | 2 +- 26 files changed, 860 insertions(+), 587 deletions(-) diff --git a/main.go b/main.go index 28208af..58bf16b 100644 --- a/main.go +++ b/main.go @@ -67,6 +67,7 @@ func main() { api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).Methods("POST") api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET") api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET") + api.HandleFunc("/auth/refresh", authHandler.RefreshToken).Methods("POST") api.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET") @@ -120,6 +121,8 @@ func main() { protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET") protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE") + protected.HandleFunc("/auth/revoke-token", authHandler.RevokeRefreshToken).Methods("POST") + protected.HandleFunc("/auth/revoke-all-tokens", authHandler.RevokeAllRefreshTokens).Methods("POST") protected.HandleFunc("/reactions/{mediaType}/{mediaId}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET") protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST") diff --git a/pkg/config/vars.go b/pkg/config/vars.go index 56ac949..470f2ca 100644 --- a/pkg/config/vars.go +++ b/pkg/config/vars.go @@ -2,25 +2,25 @@ package config const ( // Environment variable keys - EnvTMDBAccessToken = "TMDB_ACCESS_TOKEN" - EnvJWTSecret = "JWT_SECRET" - EnvPort = "PORT" - EnvBaseURL = "BASE_URL" - EnvNodeEnv = "NODE_ENV" - EnvGmailUser = "GMAIL_USER" - EnvGmailPassword = "GMAIL_APP_PASSWORD" - EnvLumexURL = "LUMEX_URL" - EnvAllohaToken = "ALLOHA_TOKEN" - EnvRedAPIBaseURL = "REDAPI_BASE_URL" - EnvRedAPIKey = "REDAPI_KEY" - EnvMongoDBName = "MONGO_DB_NAME" - EnvGoogleClientID = "GOOGLE_CLIENT_ID" - EnvGoogleClientSecret= "GOOGLE_CLIENT_SECRET" - EnvGoogleRedirectURL = "GOOGLE_REDIRECT_URL" - EnvFrontendURL = "FRONTEND_URL" - EnvVibixHost = "VIBIX_HOST" - EnvVibixToken = "VIBIX_TOKEN" - + EnvTMDBAccessToken = "TMDB_ACCESS_TOKEN" + EnvJWTSecret = "JWT_SECRET" + EnvPort = "PORT" + EnvBaseURL = "BASE_URL" + EnvNodeEnv = "NODE_ENV" + EnvGmailUser = "GMAIL_USER" + EnvGmailPassword = "GMAIL_APP_PASSWORD" + EnvLumexURL = "LUMEX_URL" + EnvAllohaToken = "ALLOHA_TOKEN" + EnvRedAPIBaseURL = "REDAPI_BASE_URL" + EnvRedAPIKey = "REDAPI_KEY" + EnvMongoDBName = "MONGO_DB_NAME" + EnvGoogleClientID = "GOOGLE_CLIENT_ID" + EnvGoogleClientSecret = "GOOGLE_CLIENT_SECRET" + EnvGoogleRedirectURL = "GOOGLE_REDIRECT_URL" + EnvFrontendURL = "FRONTEND_URL" + EnvVibixHost = "VIBIX_HOST" + EnvVibixToken = "VIBIX_TOKEN" + // Default values DefaultJWTSecret = "your-secret-key" DefaultPort = "3000" @@ -28,9 +28,9 @@ const ( DefaultNodeEnv = "development" DefaultRedAPIBase = "http://redapi.cfhttp.top" DefaultMongoDBName = "database" - DefaultVibixHost = "https://vibix.org" + DefaultVibixHost = "https://vibix.org" // Static constants TMDBImageBaseURL = "https://image.tmdb.org/t/p" CubAPIBaseURL = "https://cub.rip/api" -) \ No newline at end of file +) diff --git a/pkg/database/connection.go b/pkg/database/connection.go index 9aad860..89b40a0 100644 --- a/pkg/database/connection.go +++ b/pkg/database/connection.go @@ -38,4 +38,4 @@ func Disconnect() error { return client.Disconnect(ctx) } -func GetClient() *mongo.Client { return client } \ No newline at end of file +func GetClient() *mongo.Client { return client } diff --git a/pkg/handlers/auth.go b/pkg/handlers/auth.go index 5836268..2eaa3ea 100644 --- a/pkg/handlers/auth.go +++ b/pkg/handlers/auth.go @@ -3,8 +3,8 @@ package handlers import ( "encoding/json" "net/http" - "time" "strings" + "time" "go.mongodb.org/mongo-driver/bson" @@ -46,7 +46,14 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { return } - response, err := h.authService.Login(req) + // Получаем информацию о клиенте для refresh токена + userAgent := r.Header.Get("User-Agent") + ipAddress := r.RemoteAddr + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + ipAddress = forwarded + } + + response, err := h.authService.LoginWithTokens(req, userAgent, ipAddress) if err != nil { statusCode := http.StatusBadRequest if err.Error() == "Account not activated. Please verify your email." { @@ -74,7 +81,7 @@ func (h *AuthHandler) GoogleLogin(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() state := q.Get("state") - code := q.Get("code") + code := q.Get("code") preferJSON := q.Get("response") == "json" || strings.Contains(r.Header.Get("Accept"), "application/json") cookie, _ := r.Cookie("oauth_state") if cookie == nil || cookie.Value != state || code == "" { @@ -221,5 +228,82 @@ func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Requ json.NewEncoder(w).Encode(response) } +// RefreshToken refreshes an access token using a refresh token +func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) { + var req models.RefreshTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Получаем информацию о клиенте + userAgent := r.Header.Get("User-Agent") + ipAddress := r.RemoteAddr + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + ipAddress = forwarded + } + + tokenPair, err := h.authService.RefreshAccessToken(req.RefreshToken, userAgent, ipAddress) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Data: tokenPair, + Message: "Token refreshed successfully", + }) +} + +// RevokeRefreshToken revokes a specific refresh token +func (h *AuthHandler) RevokeRefreshToken(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserIDFromContext(r.Context()) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + var req models.RefreshTokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + err := h.authService.RevokeRefreshToken(userID, req.RefreshToken) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Message: "Refresh token revoked successfully", + }) +} + +// RevokeAllRefreshTokens revokes all refresh tokens for the current user +func (h *AuthHandler) RevokeAllRefreshTokens(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.GetUserIDFromContext(r.Context()) + if !ok { + http.Error(w, "User ID not found in context", http.StatusInternalServerError) + return + } + + err := h.authService.RevokeAllRefreshTokens(userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(models.APIResponse{ + Success: true, + Message: "All refresh tokens revoked successfully", + }) +} + // helpers -func generateState() string { return uuidNew() } \ No newline at end of file +func generateState() string { return uuidNew() } diff --git a/pkg/handlers/auth_helpers.go b/pkg/handlers/auth_helpers.go index 39c6b15..fd6f42e 100644 --- a/pkg/handlers/auth_helpers.go +++ b/pkg/handlers/auth_helpers.go @@ -4,4 +4,4 @@ import ( "github.com/google/uuid" ) -func uuidNew() string { return uuid.New().String() } \ No newline at end of file +func uuidNew() string { return uuid.New().String() } diff --git a/pkg/handlers/categories.go b/pkg/handlers/categories.go index 8b99609..d8098c1 100644 --- a/pkg/handlers/categories.go +++ b/pkg/handlers/categories.go @@ -119,4 +119,4 @@ func generateSlug(name string) string { } } return result -} \ No newline at end of file +} diff --git a/pkg/handlers/docs.go b/pkg/handlers/docs.go index 38c1d0c..1aec0d2 100644 --- a/pkg/handlers/docs.go +++ b/pkg/handlers/docs.go @@ -113,11 +113,11 @@ func determineBaseURL(r *http.Request) string { } type OpenAPISpec struct { - OpenAPI string `json:"openapi"` - Info Info `json:"info"` - Servers []Server `json:"servers"` - Paths map[string]interface{} `json:"paths"` - Components Components `json:"components"` + OpenAPI string `json:"openapi"` + Info Info `json:"info"` + Servers []Server `json:"servers"` + Paths map[string]interface{} `json:"paths"` + Components Components `json:"components"` } type Info struct { @@ -169,9 +169,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { Paths: map[string]interface{}{ "/api/v1/health": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Health Check", + "summary": "Health Check", "description": "Проверка работоспособности API", - "tags": []string{"Health"}, + "tags": []string{"Health"}, "responses": map[string]interface{}{ "200": map[string]interface{}{ "description": "API работает корректно", @@ -188,21 +188,21 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/search/multi": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Мультипоиск", + "summary": "Мультипоиск", "description": "Поиск фильмов, сериалов и актеров", - "tags": []string{"Search"}, + "tags": []string{"Search"}, "parameters": []map[string]interface{}{ { - "name": "query", - "in": "query", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "query", + "in": "query", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Поисковый запрос", }, { - "name": "page", - "in": "query", - "schema": map[string]string{"type": "integer", "default": "1"}, + "name": "page", + "in": "query", + "schema": map[string]string{"type": "integer", "default": "1"}, "description": "Номер страницы", }, }, @@ -215,16 +215,16 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/categories": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить категории", + "summary": "Получить категории", "description": "Получение списка категорий фильмов", - "tags": []string{"Categories"}, + "tags": []string{"Categories"}, "responses": map[string]interface{}{ "200": map[string]interface{}{ "description": "Список категорий", "content": map[string]interface{}{ "application/json": map[string]interface{}{ "schema": map[string]interface{}{ - "type": "array", + "type": "array", "items": map[string]interface{}{"$ref": "#/components/schemas/Category"}, }, }, @@ -235,15 +235,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/categories/{id}/movies": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Фильмы по категории", + "summary": "Фильмы по категории", "description": "Получение фильмов по категории", - "tags": []string{"Categories"}, + "tags": []string{"Categories"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID категории", }, }, @@ -256,44 +256,44 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/categories/{id}/media": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Медиа по категории", + "summary": "Медиа по категории", "description": "Получение фильмов или сериалов по категории", - "tags": []string{"Categories"}, + "tags": []string{"Categories"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID категории", }, { - "name": "type", - "in": "query", + "name": "type", + "in": "query", "required": false, "schema": map[string]interface{}{ - "type": "string", - "enum": []string{"movie", "tv"}, + "type": "string", + "enum": []string{"movie", "tv"}, "default": "movie", }, "description": "Тип медиа: movie или tv", }, { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "required": false, "schema": map[string]interface{}{ - "type": "integer", + "type": "integer", "default": 1, }, "description": "Номер страницы", }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "required": false, "schema": map[string]interface{}{ - "type": "string", + "type": "string", "default": "ru-RU", }, "description": "Язык ответа", @@ -308,15 +308,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/players/alloha/{imdb_id}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Плеер Alloha", + "summary": "Плеер Alloha", "description": "Получение плеера Alloha по IMDb ID", - "tags": []string{"Players"}, + "tags": []string{"Players"}, "parameters": []map[string]interface{}{ { - "name": "imdb_id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "imdb_id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "IMDb ID фильма", }, }, @@ -329,15 +329,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/players/lumex/{imdb_id}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Плеер Lumex", + "summary": "Плеер Lumex", "description": "Получение плеера Lumex по IMDb ID", - "tags": []string{"Players"}, + "tags": []string{"Players"}, "parameters": []map[string]interface{}{ { - "name": "imdb_id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "imdb_id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "IMDb ID фильма", }, }, @@ -376,22 +376,22 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/webtorrent/player": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "WebTorrent плеер", + "summary": "WebTorrent плеер", "description": "Открытие WebTorrent плеера с магнет ссылкой. Плеер работает полностью на стороне клиента.", - "tags": []string{"WebTorrent"}, + "tags": []string{"WebTorrent"}, "parameters": []map[string]interface{}{ { - "name": "magnet", - "in": "query", - "required": false, - "schema": map[string]string{"type": "string"}, + "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"}, + "name": "X-Magnet-Link", + "in": "header", + "required": false, + "schema": map[string]string{"type": "string"}, "description": "Магнет ссылка через заголовок (альтернативный способ)", }, }, @@ -412,15 +412,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/webtorrent/metadata": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Метаданные медиа", + "summary": "Метаданные медиа", "description": "Получение метаданных фильма или сериала по названию для WebTorrent плеера", - "tags": []string{"WebTorrent"}, + "tags": []string{"WebTorrent"}, "parameters": []map[string]interface{}{ { - "name": "query", - "in": "query", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "query", + "in": "query", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Название для поиска (извлеченное из торрента)", }, }, @@ -481,22 +481,22 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/reactions/{mediaType}/{mediaId}/counts": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Количество реакций", + "summary": "Количество реакций", "description": "Получение количества реакций для медиа", - "tags": []string{"Reactions"}, + "tags": []string{"Reactions"}, "parameters": []map[string]interface{}{ { - "name": "mediaType", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "mediaType", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Тип медиа (movie/tv)", }, { - "name": "mediaId", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "mediaId", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "ID медиа", }, }, @@ -516,22 +516,22 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/images/{size}/{path}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Изображения", + "summary": "Изображения", "description": "Прокси для изображений TMDB", - "tags": []string{"Images"}, + "tags": []string{"Images"}, "parameters": []map[string]interface{}{ { - "name": "size", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "size", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Размер изображения", }, { - "name": "path", - "in": "path", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "path", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Путь к изображению", }, }, @@ -547,9 +547,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/auth/register": map[string]interface{}{ "post": map[string]interface{}{ - "summary": "Регистрация пользователя", + "summary": "Регистрация пользователя", "description": "Создание нового аккаунта пользователя", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "requestBody": map[string]interface{}{ "required": true, "content": map[string]interface{}{ @@ -587,7 +587,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "content": map[string]interface{}{ "application/json": map[string]interface{}{ "schema": map[string]interface{}{ - "type": "object", + "type": "object", "required": []string{"email", "code"}, "properties": map[string]interface{}{ "email": map[string]interface{}{ @@ -641,7 +641,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "content": map[string]interface{}{ "application/json": map[string]interface{}{ "schema": map[string]interface{}{ - "type": "object", + "type": "object", "required": []string{"email"}, "properties": map[string]interface{}{ "email": map[string]interface{}{ @@ -682,9 +682,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/auth/login": map[string]interface{}{ "post": map[string]interface{}{ - "summary": "Авторизация пользователя", + "summary": "Авторизация пользователя", "description": "Получение JWT токена для доступа к приватным эндпоинтам", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "requestBody": map[string]interface{}{ "required": true, "content": map[string]interface{}{ @@ -714,9 +714,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/auth/profile": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить профиль пользователя", + "summary": "Получить профиль пользователя", "description": "Получение информации о текущем пользователе", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, }, @@ -734,9 +734,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, "put": map[string]interface{}{ - "summary": "Обновить профиль пользователя", + "summary": "Обновить профиль пользователя", "description": "Обновление информации о пользователе", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, }, @@ -747,9 +747,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, "delete": map[string]interface{}{ - "summary": "Удалить аккаунт пользователя", + "summary": "Удалить аккаунт пользователя", "description": "Полное и безвозвратное удаление аккаунта пользователя и всех связанных с ним данных (избранное, реакции)", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, }, @@ -779,33 +779,33 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/search": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Поиск фильмов", + "summary": "Поиск фильмов", "description": "Поиск фильмов по названию с поддержкой фильтров", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "query", - "in": "query", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "query", + "in": "query", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Поисковый запрос", }, { - "name": "page", - "in": "query", - "schema": map[string]string{"type": "integer", "default": "1"}, + "name": "page", + "in": "query", + "schema": map[string]string{"type": "integer", "default": "1"}, "description": "Номер страницы", }, { - "name": "language", - "in": "query", - "schema": map[string]string{"type": "string", "default": "ru-RU"}, + "name": "language", + "in": "query", + "schema": map[string]string{"type": "string", "default": "ru-RU"}, "description": "Язык ответа", }, { - "name": "year", - "in": "query", - "schema": map[string]string{"type": "integer"}, + "name": "year", + "in": "query", + "schema": map[string]string{"type": "integer"}, "description": "Год выпуска", }, }, @@ -825,18 +825,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/popular": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Популярные фильмы", + "summary": "Популярные фильмы", "description": "Получение списка популярных фильмов", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -849,20 +849,20 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/{id}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить фильм по ID", + "summary": "Получить фильм по ID", "description": "Подробная информация о фильме", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID фильма в TMDB", }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -882,9 +882,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/favorites": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить избранное", + "summary": "Получить избранное", "description": "Список избранных фильмов и сериалов пользователя", - "tags": []string{"Favorites"}, + "tags": []string{"Favorites"}, "security": []map[string][]string{ {"bearerAuth": []string{}}, }, @@ -897,27 +897,27 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/favorites/{id}": map[string]interface{}{ "post": map[string]interface{}{ - "summary": "Добавить в избранное", + "summary": "Добавить в избранное", "description": "Добавление фильма или сериала в избранное", - "tags": []string{"Favorites"}, + "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"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "ID медиа", }, { - "name": "type", - "in": "query", + "name": "type", + "in": "query", "required": false, "schema": map[string]interface{}{ - "type": "string", - "enum": []string{"movie", "tv"}, + "type": "string", + "enum": []string{"movie", "tv"}, "default": "movie", }, "description": "Тип медиа: movie или tv", @@ -930,27 +930,27 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, }, "delete": map[string]interface{}{ - "summary": "Удалить из избранного", + "summary": "Удалить из избранного", "description": "Удаление фильма или сериала из избранного", - "tags": []string{"Favorites"}, + "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"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "ID медиа", }, { - "name": "type", - "in": "query", + "name": "type", + "in": "query", "required": false, "schema": map[string]interface{}{ - "type": "string", - "enum": []string{"movie", "tv"}, + "type": "string", + "enum": []string{"movie", "tv"}, "default": "movie", }, "description": "Тип медиа: movie или tv", @@ -965,27 +965,27 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/favorites/{id}/check": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Проверить избранное", + "summary": "Проверить избранное", "description": "Проверка, находится ли медиа в избранном", - "tags": []string{"Favorites"}, + "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"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "ID медиа", }, { - "name": "type", - "in": "query", + "name": "type", + "in": "query", "required": false, "schema": map[string]interface{}{ - "type": "string", - "enum": []string{"movie", "tv"}, + "type": "string", + "enum": []string{"movie", "tv"}, "default": "movie", }, "description": "Тип медиа: movie или tv", @@ -1000,18 +1000,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/top-rated": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Топ рейтинг фильмов", + "summary": "Топ рейтинг фильмов", "description": "Получение списка фильмов с высоким рейтингом", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1024,18 +1024,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/upcoming": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Скоро в прокате", + "summary": "Скоро в прокате", "description": "Получение списка фильмов, которые скоро выйдут в прокат", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1048,18 +1048,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/now-playing": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Сейчас в прокате", + "summary": "Сейчас в прокате", "description": "Получение списка фильмов, которые сейчас в прокате", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1072,25 +1072,25 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/{id}/recommendations": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Рекомендации фильмов", + "summary": "Рекомендации фильмов", "description": "Получение рекомендаций фильмов на основе выбранного", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID фильма в TMDB", }, { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1103,25 +1103,25 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/{id}/similar": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Похожие фильмы", + "summary": "Похожие фильмы", "description": "Получение похожих фильмов", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID фильма в TMDB", }, { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1134,27 +1134,27 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/search": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Поиск сериалов", + "summary": "Поиск сериалов", "description": "Поиск сериалов по названию", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "query", - "in": "query", - "required": true, - "schema": map[string]string{"type": "string"}, + "name": "query", + "in": "query", + "required": true, + "schema": map[string]string{"type": "string"}, "description": "Поисковый запрос", }, { - "name": "page", - "in": "query", - "schema": map[string]string{"type": "integer", "default": "1"}, + "name": "page", + "in": "query", + "schema": map[string]string{"type": "integer", "default": "1"}, "description": "Номер страницы", }, { - "name": "language", - "in": "query", - "schema": map[string]string{"type": "string", "default": "ru-RU"}, + "name": "language", + "in": "query", + "schema": map[string]string{"type": "string", "default": "ru-RU"}, "description": "Язык ответа", }, }, @@ -1174,18 +1174,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/popular": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Популярные сериалы", + "summary": "Популярные сериалы", "description": "Получение списка популярных сериалов", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1198,18 +1198,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/top-rated": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Топ рейтинг сериалов", + "summary": "Топ рейтинг сериалов", "description": "Получение списка сериалов с высоким рейтингом", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1222,18 +1222,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/on-the-air": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "В эфире", + "summary": "В эфире", "description": "Получение списка сериалов, которые сейчас в эфире", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1246,18 +1246,18 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/airing-today": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Сегодня в эфире", + "summary": "Сегодня в эфире", "description": "Получение списка сериалов, которые выходят сегодня", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1270,20 +1270,20 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/{id}": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Получить сериал по ID", + "summary": "Получить сериал по ID", "description": "Подробная информация о сериале", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID сериала в TMDB", }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1303,25 +1303,25 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/{id}/recommendations": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Рекомендации сериалов", + "summary": "Рекомендации сериалов", "description": "Получение рекомендаций сериалов на основе выбранного", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID сериала в TMDB", }, { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1334,25 +1334,25 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/{id}/similar": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Похожие сериалы", + "summary": "Похожие сериалы", "description": "Получение похожих сериалов", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID сериала в TMDB", }, { - "name": "page", - "in": "query", + "name": "page", + "in": "query", "schema": map[string]string{"type": "integer", "default": "1"}, }, { - "name": "language", - "in": "query", + "name": "language", + "in": "query", "schema": map[string]string{"type": "string", "default": "ru-RU"}, }, }, @@ -1365,15 +1365,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/movies/{id}/external-ids": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Внешние идентификаторы фильма", + "summary": "Внешние идентификаторы фильма", "description": "Получить внешние ID (IMDb, TVDB, Facebook и др.) для фильма по TMDB ID", - "tags": []string{"Movies"}, + "tags": []string{"Movies"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID фильма в TMDB", }, }, @@ -1393,15 +1393,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/tv/{id}/external-ids": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Внешние идентификаторы сериала", + "summary": "Внешние идентификаторы сериала", "description": "Получить внешние ID (IMDb, TVDB, Facebook и др.) для сериала по TMDB ID", - "tags": []string{"TV Series"}, + "tags": []string{"TV Series"}, "parameters": []map[string]interface{}{ { - "name": "id", - "in": "path", - "required": true, - "schema": map[string]string{"type": "integer"}, + "name": "id", + "in": "path", + "required": true, + "schema": map[string]string{"type": "integer"}, "description": "ID сериала в TMDB", }, }, @@ -1421,9 +1421,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/auth/google/login": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Google OAuth: начало", + "summary": "Google OAuth: начало", "description": "Редирект на страницу авторизации Google", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "responses": map[string]interface{}{ "302": map[string]interface{}{"description": "Redirect to Google"}, "400": map[string]interface{}{"description": "OAuth не сконфигурирован"}, @@ -1432,9 +1432,9 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { }, "/api/v1/auth/google/callback": map[string]interface{}{ "get": map[string]interface{}{ - "summary": "Google OAuth: коллбек", + "summary": "Google OAuth: коллбек", "description": "Обработка кода авторизации и выдача JWT", - "tags": []string{"Authentication"}, + "tags": []string{"Authentication"}, "parameters": []map[string]interface{}{ {"name": "state", "in": "query", "required": true, "schema": map[string]string{"type": "string"}}, {"name": "code", "in": "query", "required": true, "schema": map[string]string{"type": "string"}}, @@ -1466,42 +1466,42 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "type": "object", "properties": map[string]interface{}{ "success": map[string]string{"type": "boolean"}, - "data": map[string]string{"type": "object"}, + "data": map[string]string{"type": "object"}, "message": map[string]string{"type": "string"}, - "error": map[string]string{"type": "string"}, + "error": map[string]string{"type": "string"}, }, }, "RegisterRequest": map[string]interface{}{ - "type": "object", + "type": "object", "required": []string{"email", "password", "name"}, "properties": map[string]interface{}{ "email": map[string]interface{}{ - "type": "string", - "format": "email", + "type": "string", + "format": "email", "example": "user@example.com", }, "password": map[string]interface{}{ - "type": "string", + "type": "string", "minLength": 6, - "example": "password123", + "example": "password123", }, "name": map[string]interface{}{ - "type": "string", + "type": "string", "example": "Иван Иванов", }, }, }, "LoginRequest": map[string]interface{}{ - "type": "object", + "type": "object", "required": []string{"email", "password"}, "properties": map[string]interface{}{ "email": map[string]interface{}{ - "type": "string", - "format": "email", + "type": "string", + "format": "email", "example": "user@example.com", }, "password": map[string]interface{}{ - "type": "string", + "type": "string", "example": "password123", }, }, @@ -1510,26 +1510,26 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "type": "object", "properties": map[string]interface{}{ "token": map[string]string{"type": "string"}, - "user": map[string]interface{}{"$ref": "#/components/schemas/User"}, + "user": map[string]interface{}{"$ref": "#/components/schemas/User"}, }, }, "User": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "string"}, - "email": map[string]string{"type": "string"}, - "name": map[string]string{"type": "string"}, + "id": map[string]string{"type": "string"}, + "email": map[string]string{"type": "string"}, + "name": map[string]string{"type": "string"}, "avatar": map[string]string{"type": "string"}, "favorites": map[string]interface{}{ - "type": "array", + "type": "array", "items": map[string]string{"type": "string"}, }, "created_at": map[string]interface{}{ - "type": "string", + "type": "string", "format": "date-time", }, "updated_at": map[string]interface{}{ - "type": "string", + "type": "string", "format": "date-time", }, }, @@ -1537,17 +1537,17 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "Movie": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "integer"}, - "title": map[string]string{"type": "string"}, - "original_title": map[string]string{"type": "string"}, - "overview": map[string]string{"type": "string"}, - "poster_path": map[string]string{"type": "string"}, - "backdrop_path": map[string]string{"type": "string"}, - "release_date": map[string]string{"type": "string"}, - "vote_average": map[string]string{"type": "number"}, - "vote_count": map[string]string{"type": "integer"}, - "popularity": map[string]string{"type": "number"}, - "adult": map[string]string{"type": "boolean"}, + "id": map[string]string{"type": "integer"}, + "title": map[string]string{"type": "string"}, + "original_title": map[string]string{"type": "string"}, + "overview": map[string]string{"type": "string"}, + "poster_path": map[string]string{"type": "string"}, + "backdrop_path": map[string]string{"type": "string"}, + "release_date": map[string]string{"type": "string"}, + "vote_average": map[string]string{"type": "number"}, + "vote_count": map[string]string{"type": "integer"}, + "popularity": map[string]string{"type": "number"}, + "adult": map[string]string{"type": "boolean"}, "original_language": map[string]string{"type": "string"}, }, }, @@ -1556,30 +1556,30 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "properties": map[string]interface{}{ "page": map[string]string{"type": "integer"}, "results": map[string]interface{}{ - "type": "array", + "type": "array", "items": map[string]interface{}{"$ref": "#/components/schemas/Movie"}, }, - "total_pages": map[string]string{"type": "integer"}, + "total_pages": map[string]string{"type": "integer"}, "total_results": map[string]string{"type": "integer"}, }, }, "TVSeries": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "integer"}, - "name": map[string]string{"type": "string"}, - "original_name": map[string]string{"type": "string"}, - "overview": map[string]string{"type": "string"}, - "poster_path": map[string]string{"type": "string"}, - "backdrop_path": map[string]string{"type": "string"}, - "first_air_date": map[string]string{"type": "string"}, - "vote_average": map[string]string{"type": "number"}, - "vote_count": map[string]string{"type": "integer"}, - "popularity": map[string]string{"type": "number"}, - "original_language": map[string]string{"type": "string"}, - "number_of_seasons": map[string]string{"type": "integer"}, + "id": map[string]string{"type": "integer"}, + "name": map[string]string{"type": "string"}, + "original_name": map[string]string{"type": "string"}, + "overview": map[string]string{"type": "string"}, + "poster_path": map[string]string{"type": "string"}, + "backdrop_path": map[string]string{"type": "string"}, + "first_air_date": map[string]string{"type": "string"}, + "vote_average": map[string]string{"type": "number"}, + "vote_count": map[string]string{"type": "integer"}, + "popularity": map[string]string{"type": "number"}, + "original_language": map[string]string{"type": "string"}, + "number_of_seasons": map[string]string{"type": "integer"}, "number_of_episodes": map[string]string{"type": "integer"}, - "status": map[string]string{"type": "string"}, + "status": map[string]string{"type": "string"}, }, }, "TVSearchResponse": map[string]interface{}{ @@ -1587,82 +1587,82 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "properties": map[string]interface{}{ "page": map[string]string{"type": "integer"}, "results": map[string]interface{}{ - "type": "array", + "type": "array", "items": map[string]interface{}{"$ref": "#/components/schemas/TVSeries"}, }, - "total_pages": map[string]string{"type": "integer"}, + "total_pages": map[string]string{"type": "integer"}, "total_results": map[string]string{"type": "integer"}, }, }, "Category": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "integer"}, - "name": map[string]string{"type": "string"}, + "id": map[string]string{"type": "integer"}, + "name": map[string]string{"type": "string"}, "description": map[string]string{"type": "string"}, }, }, "Player": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "url": map[string]string{"type": "string"}, - "title": map[string]string{"type": "string"}, + "url": map[string]string{"type": "string"}, + "title": map[string]string{"type": "string"}, "quality": map[string]string{"type": "string"}, - "type": map[string]string{"type": "string"}, + "type": map[string]string{"type": "string"}, }, }, "Torrent": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "title": map[string]string{"type": "string"}, - "size": map[string]string{"type": "string"}, - "seeds": map[string]string{"type": "integer"}, - "peers": map[string]string{"type": "integer"}, + "title": map[string]string{"type": "string"}, + "size": map[string]string{"type": "string"}, + "seeds": map[string]string{"type": "integer"}, + "peers": map[string]string{"type": "integer"}, "magnet": map[string]string{"type": "string"}, - "hash": map[string]string{"type": "string"}, + "hash": map[string]string{"type": "string"}, }, }, "Reaction": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "type": map[string]string{"type": "string"}, + "type": map[string]string{"type": "string"}, "count": map[string]string{"type": "integer"}, }, }, "ReactionCounts": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "like": map[string]string{"type": "integer"}, + "like": map[string]string{"type": "integer"}, "dislike": map[string]string{"type": "integer"}, - "love": map[string]string{"type": "integer"}, + "love": map[string]string{"type": "integer"}, }, }, "ExternalIDs": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "integer"}, - "imdb_id": map[string]string{"type": "string"}, - "tvdb_id": map[string]string{"type": "integer"}, - "wikidata_id": map[string]string{"type": "string"}, - "facebook_id": map[string]string{"type": "string"}, + "id": map[string]string{"type": "integer"}, + "imdb_id": map[string]string{"type": "string"}, + "tvdb_id": map[string]string{"type": "integer"}, + "wikidata_id": map[string]string{"type": "string"}, + "facebook_id": map[string]string{"type": "string"}, "instagram_id": map[string]string{"type": "string"}, - "twitter_id": map[string]string{"type": "string"}, + "twitter_id": map[string]string{"type": "string"}, }, }, "WebTorrentMetadata": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "id": map[string]string{"type": "integer"}, + "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"}, + "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"}, + "overview": map[string]string{"type": "string"}, + "runtime": map[string]string{"type": "integer"}, "genres": map[string]interface{}{ "type": "array", "items": map[string]interface{}{ @@ -1687,7 +1687,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "type": "object", "properties": map[string]interface{}{ "seasonNumber": map[string]string{"type": "integer"}, - "name": map[string]string{"type": "string"}, + "name": map[string]string{"type": "string"}, "episodes": map[string]interface{}{ "type": "array", "items": map[string]interface{}{ @@ -1700,17 +1700,17 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec { "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"}, + "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"}, + "id": map[string]string{"type": "integer"}, "name": map[string]string{"type": "string"}, }, }, diff --git a/pkg/handlers/health.go b/pkg/handlers/health.go index 0470a78..dafbec7 100644 --- a/pkg/handlers/health.go +++ b/pkg/handlers/health.go @@ -26,4 +26,4 @@ func HealthCheck(w http.ResponseWriter, r *http.Request) { }) } -var startTime = time.Now() \ No newline at end of file +var startTime = time.Now() diff --git a/pkg/handlers/images.go b/pkg/handlers/images.go index 47821f8..12d744f 100644 --- a/pkg/handlers/images.go +++ b/pkg/handlers/images.go @@ -131,4 +131,4 @@ func (h *ImagesHandler) isValidSize(size string, validSizes []string) bool { } } return false -} \ No newline at end of file +} diff --git a/pkg/handlers/movie.go b/pkg/handlers/movie.go index 3543064..26f59d9 100644 --- a/pkg/handlers/movie.go +++ b/pkg/handlers/movie.go @@ -189,8 +189,6 @@ func (h *MovieHandler) GetSimilar(w http.ResponseWriter, r *http.Request) { }) } - - func (h *MovieHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id, err := strconv.Atoi(vars["id"]) @@ -217,11 +215,11 @@ func getIntQuery(r *http.Request, key string, defaultValue int) int { if str == "" { return defaultValue } - + value, err := strconv.Atoi(str) if err != nil { return defaultValue } - + return value -} \ No newline at end of file +} diff --git a/pkg/handlers/players.go b/pkg/handlers/players.go index 682b2f9..9f9e8ac 100644 --- a/pkg/handlers/players.go +++ b/pkg/handlers/players.go @@ -10,8 +10,8 @@ import ( "strings" "time" - "neomovies-api/pkg/config" "github.com/gorilla/mux" + "neomovies-api/pkg/config" ) type PlayersHandler struct { @@ -26,29 +26,29 @@ func NewPlayersHandler(cfg *config.Config) *PlayersHandler { 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) - + imdbID := vars["imdb_id"] if imdbID == "" { log.Printf("Error: imdb_id is empty") http.Error(w, "imdb_id path param is required", http.StatusBadRequest) return } - + log.Printf("Processing imdb_id: %s", imdbID) - + if h.config.AllohaToken == "" { log.Printf("Error: ALLOHA_TOKEN is missing") http.Error(w, "Server misconfiguration: ALLOHA_TOKEN missing", http.StatusInternalServerError) return } - + idParam := fmt.Sprintf("imdb=%s", url.QueryEscape(imdbID)) 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) if err != nil { log.Printf("Error calling Alloha API: %v", err) @@ -56,105 +56,105 @@ func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) return } defer resp.Body.Close() - + log.Printf("Alloha API response status: %d", resp.StatusCode) - + 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 { log.Printf("Error reading Alloha response: %v", err) http.Error(w, "Failed to read Alloha response", http.StatusInternalServerError) return } - + log.Printf("Alloha API response body: %s", string(body)) - + var allohaResponse struct { Status string `json:"status"` - Data struct { + Data struct { Iframe string `json:"iframe"` } `json:"data"` } - + if err := json.Unmarshal(body, &allohaResponse); err != nil { log.Printf("Error unmarshaling JSON: %v", err) http.Error(w, "Invalid JSON from Alloha", http.StatusBadGateway) return } - + if allohaResponse.Status != "success" || allohaResponse.Data.Iframe == "" { log.Printf("Video not found or empty iframe") http.Error(w, "Video not found", http.StatusNotFound) return } - + iframeCode := allohaResponse.Data.Iframe if !strings.Contains(iframeCode, "<") { iframeCode = fmt.Sprintf(``, iframeCode) } - + htmlDoc := fmt.Sprintf(`Alloha Player%s`, iframeCode) - + // Авто-исправление экранированных кавычек htmlDoc = strings.ReplaceAll(htmlDoc, `\"`, `"`) htmlDoc = strings.ReplaceAll(htmlDoc, `\'`, `'`) - + w.Header().Set("Content-Type", "text/html") w.Write([]byte(htmlDoc)) - + log.Printf("Successfully served Alloha player for imdb_id: %s", imdbID) } 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) - + imdbID := vars["imdb_id"] if imdbID == "" { log.Printf("Error: imdb_id is empty") http.Error(w, "imdb_id path param is required", http.StatusBadRequest) return } - + log.Printf("Processing imdb_id: %s", imdbID) - + if h.config.LumexURL == "" { log.Printf("Error: LUMEX_URL is missing") http.Error(w, "Server misconfiguration: LUMEX_URL missing", http.StatusInternalServerError) return } - + url := fmt.Sprintf("%s?imdb_id=%s", h.config.LumexURL, url.QueryEscape(imdbID)) log.Printf("Generated Lumex URL: %s", url) - + iframe := fmt.Sprintf(``, url) htmlDoc := fmt.Sprintf(`Lumex Player%s`, iframe) - + w.Header().Set("Content-Type", "text/html") w.Write([]byte(htmlDoc)) - + log.Printf("Successfully served Lumex player for imdb_id: %s", imdbID) } 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) - + imdbID := vars["imdb_id"] if imdbID == "" { log.Printf("Error: imdb_id is empty") http.Error(w, "imdb_id path param is required", http.StatusBadRequest) return } - + log.Printf("Processing imdb_id: %s", imdbID) if h.config.VibixToken == "" { @@ -162,7 +162,7 @@ func (h *PlayersHandler) GetVibixPlayer(w http.ResponseWriter, r *http.Request) http.Error(w, "Server misconfiguration: VIBIX_TOKEN missing", http.StatusInternalServerError) return } - + vibixHost := h.config.VibixHost if vibixHost == "" { vibixHost = "https://vibix.org" @@ -177,7 +177,7 @@ func (h *PlayersHandler) GetVibixPlayer(w http.ResponseWriter, r *http.Request) http.Error(w, "Failed to create request", http.StatusInternalServerError) return } - + req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+h.config.VibixToken) req.Header.Set("X-CSRF-TOKEN", "") @@ -205,20 +205,20 @@ func (h *PlayersHandler) GetVibixPlayer(w http.ResponseWriter, r *http.Request) http.Error(w, "Failed to read Vibix response", http.StatusInternalServerError) return } - + log.Printf("Vibix API response body: %s", string(body)) var vibixResponse struct { ID interface{} `json:"id"` IframeURL string `json:"iframe_url"` } - + if err := json.Unmarshal(body, &vibixResponse); err != nil { log.Printf("Error unmarshaling Vibix JSON: %v", err) http.Error(w, "Invalid JSON from Vibix", http.StatusBadGateway) return } - + if vibixResponse.ID == nil || vibixResponse.IframeURL == "" { log.Printf("Video not found or empty iframe_url") http.Error(w, "Video not found", http.StatusNotFound) @@ -226,12 +226,12 @@ func (h *PlayersHandler) GetVibixPlayer(w http.ResponseWriter, r *http.Request) } log.Printf("Generated Vibix iframe URL: %s", vibixResponse.IframeURL) - + iframe := fmt.Sprintf(``, vibixResponse.IframeURL) htmlDoc := fmt.Sprintf(`Vibix Player%s`, iframe) - + w.Header().Set("Content-Type", "text/html") w.Write([]byte(htmlDoc)) - + log.Printf("Successfully served Vibix player for imdb_id: %s", imdbID) -} \ No newline at end of file +} diff --git a/pkg/handlers/reactions.go b/pkg/handlers/reactions.go index 8128d40..abba5ed 100644 --- a/pkg/handlers/reactions.go +++ b/pkg/handlers/reactions.go @@ -85,7 +85,9 @@ func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) { return } - var request struct{ Type string `json:"type"` } + var request struct { + Type string `json:"type"` + } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return @@ -146,4 +148,4 @@ func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: reactions}) -} \ No newline at end of file +} diff --git a/pkg/handlers/search.go b/pkg/handlers/search.go index 231140a..9f2f700 100644 --- a/pkg/handlers/search.go +++ b/pkg/handlers/search.go @@ -42,4 +42,4 @@ func (h *SearchHandler) MultiSearch(w http.ResponseWriter, r *http.Request) { Success: true, Data: results, }) -} \ No newline at end of file +} diff --git a/pkg/handlers/torrents.go b/pkg/handlers/torrents.go index b491996..f8a4992 100644 --- a/pkg/handlers/torrents.go +++ b/pkg/handlers/torrents.go @@ -123,12 +123,12 @@ func (h *TorrentsHandler) SearchTorrents(w http.ResponseWriter, r *http.Request) // Группируем сначала по сезонам, затем по качеству внутри каждого сезона seasonGroups := h.torrentService.GroupBySeason(results.Results) finalGroups := make(map[string]map[string][]models.TorrentResult) - + for season, torrents := range seasonGroups { qualityGroups := h.torrentService.GroupByQuality(torrents) finalGroups[season] = qualityGroups } - + response["grouped"] = true response["groups"] = finalGroups } else if options.GroupByQuality { @@ -364,4 +364,4 @@ func (h *TorrentsHandler) SearchByQuery(w http.ResponseWriter, r *http.Request) Success: true, Data: response, }) -} \ No newline at end of file +} diff --git a/pkg/handlers/tv.go b/pkg/handlers/tv.go index 0a4fb34..95a1965 100644 --- a/pkg/handlers/tv.go +++ b/pkg/handlers/tv.go @@ -203,4 +203,4 @@ func (h *TVHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) { Success: true, Data: externalIDs, }) -} \ No newline at end of file +} diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index f62fee5..80dd770 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -60,4 +60,4 @@ func JWTAuth(secret string) func(http.Handler) http.Handler { func GetUserIDFromContext(ctx context.Context) (string, bool) { userID, ok := ctx.Value(UserIDKey).(string) return userID, ok -} \ No newline at end of file +} diff --git a/pkg/models/favorite.go b/pkg/models/favorite.go index 301da36..ef48d8f 100644 --- a/pkg/models/favorite.go +++ b/pkg/models/favorite.go @@ -21,4 +21,4 @@ type FavoriteRequest struct { 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/user.go b/pkg/models/user.go index 3474acc..6f42f0f 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -7,21 +7,22 @@ import ( ) type User struct { - ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` - Email string `json:"email" bson:"email" validate:"required,email"` - Password string `json:"-" bson:"password" validate:"required,min=6"` - Name string `json:"name" bson:"name" validate:"required"` - Avatar string `json:"avatar" bson:"avatar"` - Favorites []string `json:"favorites" bson:"favorites"` - Verified bool `json:"verified" bson:"verified"` - VerificationCode string `json:"-" bson:"verificationCode,omitempty"` - VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"` - IsAdmin bool `json:"isAdmin" bson:"isAdmin"` - AdminVerified bool `json:"adminVerified" bson:"adminVerified"` - CreatedAt time.Time `json:"created_at" bson:"createdAt"` - UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"` - Provider string `json:"provider,omitempty" bson:"provider,omitempty"` - GoogleID string `json:"googleId,omitempty" bson:"googleId,omitempty"` + ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` + Email string `json:"email" bson:"email" validate:"required,email"` + Password string `json:"-" bson:"password" validate:"required,min=6"` + Name string `json:"name" bson:"name" validate:"required"` + Avatar string `json:"avatar" bson:"avatar"` + Favorites []string `json:"favorites" bson:"favorites"` + Verified bool `json:"verified" bson:"verified"` + VerificationCode string `json:"-" bson:"verificationCode,omitempty"` + VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"` + IsAdmin bool `json:"isAdmin" bson:"isAdmin"` + AdminVerified bool `json:"adminVerified" bson:"adminVerified"` + CreatedAt time.Time `json:"created_at" bson:"createdAt"` + UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"` + Provider string `json:"provider,omitempty" bson:"provider,omitempty"` + GoogleID string `json:"googleId,omitempty" bson:"googleId,omitempty"` + RefreshTokens []RefreshToken `json:"-" bson:"refreshTokens,omitempty"` } type LoginRequest struct { @@ -36,8 +37,9 @@ type RegisterRequest struct { } type AuthResponse struct { - Token string `json:"token"` - User User `json:"user"` + Token string `json:"token"` + RefreshToken string `json:"refreshToken"` + User User `json:"user"` } type VerifyEmailRequest struct { @@ -47,4 +49,21 @@ type VerifyEmailRequest struct { type ResendCodeRequest struct { Email string `json:"email" validate:"required,email"` -} \ No newline at end of file +} + +type RefreshToken struct { + Token string `json:"token" bson:"token"` + ExpiresAt time.Time `json:"expiresAt" bson:"expiresAt"` + CreatedAt time.Time `json:"createdAt" bson:"createdAt"` + UserAgent string `json:"userAgent,omitempty" bson:"userAgent,omitempty"` + IPAddress string `json:"ipAddress,omitempty" bson:"ipAddress,omitempty"` +} + +type TokenPair struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` +} + +type RefreshTokenRequest struct { + RefreshToken string `json:"refreshToken" validate:"required"` +} diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go index 4d91c8d..652eab5 100644 --- a/pkg/monitor/monitor.go +++ b/pkg/monitor/monitor.go @@ -12,16 +12,16 @@ func RequestMonitor() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() - + // Создаем wrapper для ResponseWriter чтобы получить статус код ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} - + // Выполняем запрос next.ServeHTTP(ww, r) - + // Вычисляем время выполнения duration := time.Since(start) - + // Форматируем URL (обрезаем если слишком длинный) url := r.URL.Path if r.URL.RawQuery != "" { @@ -30,11 +30,11 @@ func RequestMonitor() func(http.Handler) http.Handler { if len(url) > 60 { url = url[:57] + "..." } - + // Определяем цвет статуса statusColor := getStatusColor(ww.statusCode) methodColor := getMethodColor(r.Method) - + // Выводим информацию о запросе fmt.Printf("\033[2K\r%s%-6s\033[0m %s%-3d\033[0m │ %-60s │ %6.2fms\n", methodColor, r.Method, @@ -88,4 +88,4 @@ func getMethodColor(method string) string { default: return "\033[37m" // Белый } -} \ No newline at end of file +} diff --git a/pkg/services/auth.go b/pkg/services/auth.go index 2512a08..8b2f1c2 100644 --- a/pkg/services/auth.go +++ b/pkg/services/auth.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "encoding/json" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "go.mongodb.org/mongo-driver/bson" @@ -19,17 +20,16 @@ import ( "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" "golang.org/x/oauth2/google" - "encoding/json" "neomovies-api/pkg/models" ) // AuthService contains the database connection, JWT secret, and email service. type AuthService struct { - db *mongo.Database - jwtSecret string - emailService *EmailService - baseURL string + db *mongo.Database + jwtSecret string + emailService *EmailService + baseURL string googleClientID string googleClientSecret string googleRedirectURL string @@ -38,18 +38,18 @@ type AuthService struct { // Reaction represents a reaction entry in the database. type Reaction struct { - MediaID string `bson:"mediaId"` - Type string `bson:"type"` - UserID primitive.ObjectID `bson:"userId"` + MediaID string `bson:"mediaId"` + Type string `bson:"type"` + UserID primitive.ObjectID `bson:"userId"` } // NewAuthService creates and initializes a new AuthService. func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService, baseURL string, googleClientID string, googleClientSecret string, googleRedirectURL string, frontendURL string) *AuthService { service := &AuthService{ - db: db, - jwtSecret: jwtSecret, - emailService: emailService, - baseURL: baseURL, + db: db, + jwtSecret: jwtSecret, + emailService: emailService, + baseURL: baseURL, googleClientID: googleClientID, googleClientSecret: googleClientSecret, googleRedirectURL: googleRedirectURL, @@ -81,11 +81,11 @@ func (s *AuthService) GetGoogleLoginURL(state string) (string, error) { } type googleUserInfo struct { - Sub string `json:"sub"` - Email string `json:"email"` - Name string `json:"name"` - Picture string `json:"picture"` - EmailVerified bool `json:"email_verified"` + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + Picture string `json:"picture"` + EmailVerified bool `json:"email_verified"` } // BuildFrontendRedirect builds frontend URL for redirect after OAuth; returns false if not configured @@ -149,19 +149,19 @@ func (s *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*m if err == mongo.ErrNoDocuments { // Create new user user = models.User{ - ID: primitive.NewObjectID(), - Email: gUser.Email, - Password: "", - Name: gUser.Name, - Avatar: gUser.Picture, - Favorites: []string{}, - Verified: true, - IsAdmin: false, + ID: primitive.NewObjectID(), + Email: gUser.Email, + Password: "", + Name: gUser.Name, + Avatar: gUser.Picture, + Favorites: []string{}, + Verified: true, + IsAdmin: false, AdminVerified: false, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Provider: "google", - GoogleID: gUser.Sub, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Provider: "google", + GoogleID: gUser.Sub, } if _, err := collection.InsertOne(ctx, user); err != nil { return nil, err @@ -171,13 +171,17 @@ func (s *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*m } else { // Existing user: ensure fields update := bson.M{ - "verified": true, - "provider": "google", - "googleId": gUser.Sub, + "verified": true, + "provider": "google", + "googleId": gUser.Sub, "updatedAt": time.Now(), } - if user.Name == "" && gUser.Name != "" { update["name"] = gUser.Name } - if user.Avatar == "" && gUser.Picture != "" { update["avatar"] = gUser.Picture } + if user.Name == "" && gUser.Name != "" { + update["name"] = gUser.Name + } + if user.Avatar == "" && gUser.Picture != "" { + update["avatar"] = gUser.Picture + } _, _ = collection.UpdateOne(ctx, bson.M{"_id": user.ID}, bson.M{"$set": update}) } @@ -186,10 +190,16 @@ func (s *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*m // If we created user above, we already have user.ID set; else fetch updated _ = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user) } - token, err := s.generateJWT(user.ID.Hex()) - if err != nil { return nil, err } + tokenPair, err := s.generateTokenPair(user.ID.Hex(), "", "") + if err != nil { + return nil, err + } - return &models.AuthResponse{ Token: token, User: user }, nil + return &models.AuthResponse{ + Token: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + User: user, + }, nil } // generateVerificationCode creates a 6-digit verification code. @@ -216,18 +226,18 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface codeExpires := time.Now().Add(10 * time.Minute) user := models.User{ - ID: primitive.NewObjectID(), - Email: req.Email, - Password: string(hashedPassword), - Name: req.Name, - Favorites: []string{}, - Verified: false, - VerificationCode: code, + ID: primitive.NewObjectID(), + Email: req.Email, + Password: string(hashedPassword), + Name: req.Name, + Favorites: []string{}, + Verified: false, + VerificationCode: code, VerificationExpires: codeExpires, - IsAdmin: false, - AdminVerified: false, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + IsAdmin: false, + AdminVerified: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } _, err = collection.InsertOne(context.Background(), user) @@ -246,9 +256,9 @@ func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface } // Login authenticates a user. -func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) { +func (s *AuthService) LoginWithTokens(req models.LoginRequest, userAgent, ipAddress string) (*models.AuthResponse, error) { collection := s.db.Collection("users") - + var user models.User err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user) if err != nil { @@ -264,17 +274,23 @@ func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, erro return nil, errors.New("Invalid password") } - token, err := s.generateJWT(user.ID.Hex()) + tokenPair, err := s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress) if err != nil { return nil, err } return &models.AuthResponse{ - Token: token, - User: user, + Token: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + User: user, }, nil } +// Login authenticates a user (legacy method for backward compatibility). +func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) { + return s.LoginWithTokens(req, "", "") +} + // GetUserByID retrieves a user by their ID. func (s *AuthService) GetUserByID(userID string) (*models.User, error) { collection := s.db.Collection("users") @@ -320,7 +336,7 @@ func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, e func (s *AuthService) generateJWT(userID string) (string, error) { claims := jwt.MapClaims{ "user_id": userID, - "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), + "exp": time.Now().Add(time.Hour * 1).Unix(), // Сократил время жизни до 1 часа "iat": time.Now().Unix(), "jti": uuid.New().String(), } @@ -329,6 +345,158 @@ func (s *AuthService) generateJWT(userID string) (string, error) { return token.SignedString([]byte(s.jwtSecret)) } +// generateRefreshToken generates a new refresh token +func (s *AuthService) generateRefreshToken() string { + return uuid.New().String() +} + +// generateTokenPair generates both access and refresh tokens +func (s *AuthService) generateTokenPair(userID, userAgent, ipAddress string) (*models.TokenPair, error) { + accessToken, err := s.generateJWT(userID) + if err != nil { + return nil, err + } + + refreshToken := s.generateRefreshToken() + + // Сохраняем refresh token в базе данных + collection := s.db.Collection("users") + objectID, err := primitive.ObjectIDFromHex(userID) + if err != nil { + return nil, err + } + + refreshTokenDoc := models.RefreshToken{ + Token: refreshToken, + ExpiresAt: time.Now().Add(time.Hour * 24 * 30), // 30 дней + CreatedAt: time.Now(), + UserAgent: userAgent, + IPAddress: ipAddress, + } + + // Удаляем старые истекшие токены и добавляем новый + _, err = collection.UpdateOne( + context.Background(), + bson.M{"_id": objectID}, + bson.M{ + "$pull": bson.M{ + "refreshTokens": bson.M{ + "expiresAt": bson.M{"$lt": time.Now()}, + }, + }, + }, + ) + if err != nil { + return nil, err + } + + _, err = collection.UpdateOne( + context.Background(), + bson.M{"_id": objectID}, + bson.M{ + "$push": bson.M{ + "refreshTokens": refreshTokenDoc, + }, + "$set": bson.M{ + "updatedAt": time.Now(), + }, + }, + ) + if err != nil { + return nil, err + } + + return &models.TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + }, nil +} + +// RefreshAccessToken refreshes an access token using a refresh token +func (s *AuthService) RefreshAccessToken(refreshToken, userAgent, ipAddress string) (*models.TokenPair, error) { + collection := s.db.Collection("users") + + // Найти пользователя с данным refresh токеном + var user models.User + err := collection.FindOne( + context.Background(), + bson.M{ + "refreshTokens": bson.M{ + "$elemMatch": bson.M{ + "token": refreshToken, + "expiresAt": bson.M{"$gt": time.Now()}, + }, + }, + }, + ).Decode(&user) + + if err != nil { + return nil, errors.New("invalid or expired refresh token") + } + + // Удалить использованный refresh token + _, err = collection.UpdateOne( + context.Background(), + bson.M{"_id": user.ID}, + bson.M{ + "$pull": bson.M{ + "refreshTokens": bson.M{ + "token": refreshToken, + }, + }, + }, + ) + if err != nil { + return nil, err + } + + // Создать новую пару токенов + return s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress) +} + +// RevokeRefreshToken revokes a specific refresh token +func (s *AuthService) RevokeRefreshToken(userID, refreshToken string) error { + collection := s.db.Collection("users") + objectID, err := primitive.ObjectIDFromHex(userID) + if err != nil { + return err + } + + _, err = collection.UpdateOne( + context.Background(), + bson.M{"_id": objectID}, + bson.M{ + "$pull": bson.M{ + "refreshTokens": bson.M{ + "token": refreshToken, + }, + }, + }, + ) + return err +} + +// RevokeAllRefreshTokens revokes all refresh tokens for a user +func (s *AuthService) RevokeAllRefreshTokens(userID string) error { + collection := s.db.Collection("users") + objectID, err := primitive.ObjectIDFromHex(userID) + if err != nil { + return err + } + + _, err = collection.UpdateOne( + context.Background(), + bson.M{"_id": objectID}, + bson.M{ + "$set": bson.M{ + "refreshTokens": []models.RefreshToken{}, + "updatedAt": time.Now(), + }, + }, + ) + return err +} + // VerifyEmail verifies a user's email with a code. func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) { collection := s.db.Collection("users") @@ -439,20 +607,20 @@ func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error { go func(r Reaction) { defer wg.Done() url := fmt.Sprintf("%s/reactions/remove/%s/%s", s.baseURL, r.MediaID, r.Type) // Changed from cubAPIURL to baseURL - req, err := http.NewRequestWithContext(ctx, "POST", url, nil) // or "DELETE" + req, err := http.NewRequestWithContext(ctx, "POST", url, nil) // or "DELETE" if err != nil { // Log the error but don't stop the process fmt.Printf("failed to create request for cub.rip: %v\n", err) return } - + resp, err := client.Do(req) if err != nil { fmt.Printf("failed to send request to cub.rip: %v\n", err) return } defer resp.Body.Close() - + if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) fmt.Printf("cub.rip API responded with status %d: %s\n", resp.StatusCode, body) diff --git a/pkg/services/email.go b/pkg/services/email.go index bbb722c..4a5ad60 100644 --- a/pkg/services/email.go +++ b/pkg/services/email.go @@ -98,7 +98,7 @@ func (s *EmailService) SendVerificationEmail(userEmail, code string) error { func (s *EmailService) SendPasswordResetEmail(userEmail, resetToken string) error { resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, resetToken) - + options := &EmailOptions{ To: []string{userEmail}, Subject: "Сброс пароля Neo Movies", @@ -147,4 +147,4 @@ func (s *EmailService) SendMovieRecommendationEmail(userEmail, userName string, } return s.SendEmail(options) -} \ No newline at end of file +} diff --git a/pkg/services/movie.go b/pkg/services/movie.go index dde7a10..6450067 100644 --- a/pkg/services/movie.go +++ b/pkg/services/movie.go @@ -48,8 +48,6 @@ func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBRe return s.tmdb.GetSimilarMovies(id, page, language) } - - func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) { return s.tmdb.GetMovieExternalIDs(id) -} \ No newline at end of file +} diff --git a/pkg/services/reactions.go b/pkg/services/reactions.go index 8ca2489..5082fdb 100644 --- a/pkg/services/reactions.go +++ b/pkg/services/reactions.go @@ -33,7 +33,7 @@ var validReactions = []string{"fire", "nice", "think", "bore", "shit"} // Получить счетчики реакций для медиа из внешнего API (cub.rip) func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) { cubID := fmt.Sprintf("%s_%s", mediaType, mediaID) - + resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", config.CubAPIBaseURL, cubID)) if err != nil { return &models.ReactionCounts{}, nil @@ -83,7 +83,9 @@ func (s *ReactionsService) GetMyReaction(userID, mediaType, mediaID string) (str collection := s.db.Collection("reactions") ctx := context.Background() - var result struct{ Type string `bson:"type"` } + var result struct { + Type string `bson:"type"` + } err := collection.FindOne(ctx, bson.M{ "userId": userID, "mediaType": mediaType, @@ -165,7 +167,7 @@ func (s *ReactionsService) isValidReactionType(reactionType string) bool { // Отправка реакции в cub.rip API (асинхронно) func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) { url := fmt.Sprintf("%s/reactions/set", config.CubAPIBaseURL) - + data := map[string]string{ "mediaId": mediaID, "type": reactionType, @@ -185,4 +187,4 @@ func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) { if resp.StatusCode == http.StatusOK { fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType) } -} \ No newline at end of file +} diff --git a/pkg/services/tmdb.go b/pkg/services/tmdb.go index 1453501..dc37abc 100644 --- a/pkg/services/tmdb.go +++ b/pkg/services/tmdb.go @@ -52,23 +52,23 @@ func (s *TMDBService) SearchMovies(query string, page int, language, region stri params.Set("query", query) params.Set("page", strconv.Itoa(page)) params.Set("include_adult", "false") - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if region != "" { params.Set("region", region) } - + if year > 0 { params.Set("year", strconv.Itoa(year)) } endpoint := fmt.Sprintf("%s/search/movie?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -129,19 +129,19 @@ func (s *TMDBService) SearchTVShows(query string, page int, language string, fir params.Set("query", query) params.Set("page", strconv.Itoa(page)) params.Set("include_adult", "false") - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if firstAirDateYear > 0 { params.Set("first_air_date_year", strconv.Itoa(firstAirDateYear)) } endpoint := fmt.Sprintf("%s/search/tv?%s", s.baseURL, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -149,7 +149,7 @@ func (s *TMDBService) SearchTVShows(query string, page int, language string, fir func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) { params := url.Values{} - + if language != "" { params.Set("language", language) } else { @@ -157,7 +157,7 @@ func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) { } endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode()) - + var movie models.Movie err := s.makeRequest(endpoint, &movie) return &movie, err @@ -165,7 +165,7 @@ func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) { func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) { params := url.Values{} - + if language != "" { params.Set("language", language) } else { @@ -173,7 +173,7 @@ func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) } endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode()) - + var tvShow models.TVShow err := s.makeRequest(endpoint, &tvShow) return &tvShow, err @@ -181,7 +181,7 @@ func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) { params := url.Values{} - + if language != "" { params.Set("language", language) } else { @@ -189,7 +189,7 @@ func (s *TMDBService) GetGenres(mediaType string, language string) (*models.Genr } endpoint := fmt.Sprintf("%s/genre/%s/list?%s", s.baseURL, mediaType, params.Encode()) - + var response models.GenresResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -210,11 +210,11 @@ func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) { // Объединяем жанры, убирая дубликаты allGenres := make(map[int]models.Genre) - + for _, genre := range movieGenres.Genres { allGenres[genre.ID] = genre } - + for _, genre := range tvGenres.Genres { allGenres[genre.ID] = genre } @@ -231,19 +231,19 @@ func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) { func (s *TMDBService) GetPopularMovies(page int, language, region string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if region != "" { params.Set("region", region) } endpoint := fmt.Sprintf("%s/movie/popular?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -252,19 +252,19 @@ func (s *TMDBService) GetPopularMovies(page int, language, region string) (*mode func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if region != "" { params.Set("region", region) } endpoint := fmt.Sprintf("%s/movie/top_rated?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -273,19 +273,19 @@ func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*mod func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if region != "" { params.Set("region", region) } endpoint := fmt.Sprintf("%s/movie/upcoming?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -294,19 +294,19 @@ func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*mod func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { params.Set("language", "ru-RU") } - + if region != "" { params.Set("region", region) } endpoint := fmt.Sprintf("%s/movie/now_playing?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -315,7 +315,7 @@ func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*m func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -323,7 +323,7 @@ func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*m } endpoint := fmt.Sprintf("%s/movie/%d/recommendations?%s", s.baseURL, id, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -332,7 +332,7 @@ func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*m func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.TMDBResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -340,7 +340,7 @@ func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.T } endpoint := fmt.Sprintf("%s/movie/%d/similar?%s", s.baseURL, id, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -349,7 +349,7 @@ func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.T func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -357,7 +357,7 @@ func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDB } endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -366,7 +366,7 @@ func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDB func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -374,7 +374,7 @@ func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMD } endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -383,7 +383,7 @@ func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMD func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -391,7 +391,7 @@ func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMD } endpoint := fmt.Sprintf("%s/tv/on_the_air?%s", s.baseURL, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -400,7 +400,7 @@ func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMD func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -408,7 +408,7 @@ func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models. } endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -417,7 +417,7 @@ func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models. func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -425,7 +425,7 @@ func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*mode } endpoint := fmt.Sprintf("%s/tv/%d/recommendations?%s", s.baseURL, id, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -434,7 +434,7 @@ func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*mode func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.TMDBTVResponse, error) { params := url.Values{} params.Set("page", strconv.Itoa(page)) - + if language != "" { params.Set("language", language) } else { @@ -442,7 +442,7 @@ func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models. } endpoint := fmt.Sprintf("%s/tv/%d/similar?%s", s.baseURL, id, params.Encode()) - + var response models.TMDBTVResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -450,7 +450,7 @@ func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models. func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) { endpoint := fmt.Sprintf("%s/movie/%d/external_ids", s.baseURL, id) - + var ids models.ExternalIDs err := s.makeRequest(endpoint, &ids) return &ids, err @@ -458,7 +458,7 @@ func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) { func (s *TMDBService) GetTVExternalIDs(id int) (*models.ExternalIDs, error) { endpoint := fmt.Sprintf("%s/tv/%d/external_ids", s.baseURL, id) - + var ids models.ExternalIDs err := s.makeRequest(endpoint, &ids) return &ids, err @@ -469,7 +469,7 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) 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 { @@ -477,7 +477,7 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) } endpoint := fmt.Sprintf("%s/discover/movie?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -488,7 +488,7 @@ func (s *TMDBService) DiscoverTVByGenre(genreID, page int, language string) (*mo 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 { @@ -496,7 +496,7 @@ func (s *TMDBService) DiscoverTVByGenre(genreID, page int, language string) (*mo } endpoint := fmt.Sprintf("%s/discover/tv?%s", s.baseURL, params.Encode()) - + var response models.TMDBResponse err := s.makeRequest(endpoint, &response) return &response, err @@ -508,8 +508,8 @@ func (s *TMDBService) GetTVSeason(tvID, seasonNumber int, language string) (*mod } 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 +} diff --git a/pkg/services/torrent.go b/pkg/services/torrent.go index 5a5064b..d22eac1 100644 --- a/pkg/services/torrent.go +++ b/pkg/services/torrent.go @@ -207,7 +207,6 @@ func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID return response, nil } - // SearchMovies - поиск фильмов с дополнительной фильтрацией func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) { params := map[string]string{ @@ -850,7 +849,7 @@ func (s *TorrentService) SearchByImdb(imdbID, contentType string, season *int) ( "is_serial": "2", "category": "5000", } - + fallbackResp, err := s.SearchTorrents(paramsNoSeason) if err == nil { filtered := s.filterBySeason(fallbackResp.Results, *season) diff --git a/pkg/services/tv.go b/pkg/services/tv.go index 5523a00..18dea61 100644 --- a/pkg/services/tv.go +++ b/pkg/services/tv.go @@ -52,4 +52,4 @@ func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVRes func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) { return s.tmdb.GetTVExternalIDs(id) -} \ No newline at end of file +}