mirror of
				https://gitlab.com/foxixus/neomovies-api.git
				synced 2025-10-28 18:08:51 +05:00 
			
		
		
		
	Compare commits
	
		
			72 Commits
		
	
	
		
			53a405a743
			...
			feature/ad
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1cdc35370b | |||
|  | a8cdfd9a5f | ||
| a31cdf0f75 | |||
| dfcd9db295 | |||
| 59334da140 | |||
| 04583418a1 | |||
| 42073ea7b4 | |||
| a2e015aa53 | |||
| 552e60440c | |||
| fcb6caf1b9 | |||
| bb64b2dde4 | |||
| 86034c8e12 | |||
| f3c1cab796 | |||
| d790eb7903 | |||
| d347c6003a | |||
| c8cf79d764 | |||
| 206aa770b6 | |||
| 9db1ee3f50 | |||
| 171a2bf3ed | |||
| 486bbf5475 | |||
| 12ed40f3d4 | |||
| 53d70c9262 | |||
| 7f6ff5f660 | |||
| 4a9a7febec | |||
| 66cd0d3b21 | |||
| 92b936f057 | |||
| 9cd3d45327 | |||
| efcc5cd2b9 | |||
| a575b5c5bf | |||
| 51af31a6d5 | |||
| 0b2dc6b2f4 | |||
| cc463c4d7c | |||
| 94968f3cd1 | |||
| dff5e963ab | |||
| cf5dfc7e54 | |||
| 1005f30285 | |||
| ea3c208292 | |||
| 5ce5da39bb | |||
| 58a32d8838 | |||
| 770ecef6d5 | |||
| 37040dd7ec | |||
| 7aa0307e25 | |||
| d961393562 | |||
| e1e2b4f92b | |||
| 6b063f4c70 | |||
| 95910e0710 | |||
| 02dedbb8f7 | |||
| 6bf00451fa | |||
| 600de04561 | |||
| 7a83bf2e27 | |||
| 1fd522872d | |||
| 0f751cced0 | |||
| 4cb06cbde5 | |||
| 4f23e979d5 | |||
| c25d4e5d87 | |||
| 5361894af1 | |||
| a5eb03aea8 | |||
| a04b4f7c12 | |||
| 3a86c14129 | |||
| 498bc41c1b | |||
| 4d73fc9d8c | |||
| 2d25162b1c | |||
| af5957b4f9 | |||
| a724bf0484 | |||
| 8b11f89347 | |||
| 0c1cfb1ac5 | |||
| 868e71991c | |||
| 5f859eebb8 | |||
| 3a6ac8db4b | |||
| 60c574849b | |||
| 037ab7a458 | |||
| 9d60080116 | 
							
								
								
									
										28
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | # Required | ||||||
|  | MONGO_URI= | ||||||
|  | MONGO_DB_NAME=database | ||||||
|  | TMDB_ACCESS_TOKEN= | ||||||
|  | JWT_SECRET= | ||||||
|  |  | ||||||
|  | # Service | ||||||
|  | PORT=3000 | ||||||
|  | BASE_URL=http://localhost:3000 | ||||||
|  | NODE_ENV=development | ||||||
|  |  | ||||||
|  | # Email (Gmail) | ||||||
|  | GMAIL_USER= | ||||||
|  | GMAIL_APP_PASSWORD= | ||||||
|  |  | ||||||
|  | # Players | ||||||
|  | LUMEX_URL= | ||||||
|  | ALLOHA_TOKEN= | ||||||
|  |  | ||||||
|  | # Torrents (RedAPI) | ||||||
|  | REDAPI_BASE_URL=http://redapi.cfhttp.top | ||||||
|  | REDAPI_KEY= | ||||||
|  |  | ||||||
|  | # Google OAuth | ||||||
|  | GOOGLE_CLIENT_ID= | ||||||
|  | GOOGLE_CLIENT_SECRET= | ||||||
|  | GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback | ||||||
|  | FRONTEND_URL=http://localhost:3001 | ||||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | .env | ||||||
|  | .env.local | ||||||
|  | node_modules | ||||||
|  | package-lock.json | ||||||
|  | yarn.lock | ||||||
							
								
								
									
										201
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | |||||||
|  |                                  Apache License | ||||||
|  |                            Version 2.0, January 2004 | ||||||
|  |                         http://www.apache.org/licenses/ | ||||||
|  |  | ||||||
|  |    TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION | ||||||
|  |  | ||||||
|  |    1. Definitions. | ||||||
|  |  | ||||||
|  |       "License" shall mean the terms and conditions for use, reproduction, | ||||||
|  |       and distribution as defined by Sections 1 through 9 of this document. | ||||||
|  |  | ||||||
|  |       "Licensor" shall mean the copyright owner or entity authorized by | ||||||
|  |       the copyright owner that is granting the License. | ||||||
|  |  | ||||||
|  |       "Legal Entity" shall mean the union of the acting entity and all | ||||||
|  |       other entities that control, are controlled by, or are under common | ||||||
|  |       control with that entity. For the purposes of this definition, | ||||||
|  |       "control" means (i) the power, direct or indirect, to cause the | ||||||
|  |       direction or management of such entity, whether by contract or | ||||||
|  |       otherwise, or (ii) ownership of fifty percent (50%) or more of the | ||||||
|  |       outstanding shares, or (iii) beneficial ownership of such entity. | ||||||
|  |  | ||||||
|  |       "You" (or "Your") shall mean an individual or Legal Entity | ||||||
|  |       exercising permissions granted by this License. | ||||||
|  |  | ||||||
|  |       "Source" form shall mean the preferred form for making modifications, | ||||||
|  |       including but not limited to software source code, documentation | ||||||
|  |       source, and configuration files. | ||||||
|  |  | ||||||
|  |       "Object" form shall mean any form resulting from mechanical | ||||||
|  |       transformation or translation of a Source form, including but | ||||||
|  |       not limited to compiled object code, generated documentation, | ||||||
|  |       and conversions to other media types. | ||||||
|  |  | ||||||
|  |       "Work" shall mean the work of authorship, whether in Source or | ||||||
|  |       Object form, made available under the License, as indicated by a | ||||||
|  |       copyright notice that is included in or attached to the work | ||||||
|  |       (an example is provided in the Appendix below). | ||||||
|  |  | ||||||
|  |       "Derivative Works" shall mean any work, whether in Source or Object | ||||||
|  |       form, that is based on (or derived from) the Work and for which the | ||||||
|  |       editorial revisions, annotations, elaborations, or other modifications | ||||||
|  |       represent, as a whole, an original work of authorship. For the purposes | ||||||
|  |       of this License, Derivative Works shall not include works that remain | ||||||
|  |       separable from, or merely link (or bind by name) to the interfaces of, | ||||||
|  |       the Work and Derivative Works thereof. | ||||||
|  |  | ||||||
|  |       "Contribution" shall mean any work of authorship, including | ||||||
|  |       the original version of the Work and any modifications or additions | ||||||
|  |       to that Work or Derivative Works thereof, that is intentionally | ||||||
|  |       submitted to Licensor for inclusion in the Work by the copyright owner | ||||||
|  |       or by an individual or Legal Entity authorized to submit on behalf of | ||||||
|  |       the copyright owner. For the purposes of this definition, "submitted" | ||||||
|  |       means any form of electronic, verbal, or written communication sent | ||||||
|  |       to the Licensor or its representatives, including but not limited to | ||||||
|  |       communication on electronic mailing lists, source code control systems, | ||||||
|  |       and issue tracking systems that are managed by, or on behalf of, the | ||||||
|  |       Licensor for the purpose of discussing and improving the Work, but | ||||||
|  |       excluding communication that is conspicuously marked or otherwise | ||||||
|  |       designated in writing by the copyright owner as "Not a Contribution." | ||||||
|  |  | ||||||
|  |       "Contributor" shall mean Licensor and any individual or Legal Entity | ||||||
|  |       on behalf of whom a Contribution has been received by Licensor and | ||||||
|  |       subsequently incorporated within the Work. | ||||||
|  |  | ||||||
|  |    2. Grant of Copyright License. Subject to the terms and conditions of | ||||||
|  |       this License, each Contributor hereby grants to You a perpetual, | ||||||
|  |       worldwide, non-exclusive, no-charge, royalty-free, irrevocable | ||||||
|  |       copyright license to reproduce, prepare Derivative Works of, | ||||||
|  |       publicly display, publicly perform, sublicense, and distribute the | ||||||
|  |       Work and such Derivative Works in Source or Object form. | ||||||
|  |  | ||||||
|  |    3. Grant of Patent License. Subject to the terms and conditions of | ||||||
|  |       this License, each Contributor hereby grants to You a perpetual, | ||||||
|  |       worldwide, non-exclusive, no-charge, royalty-free, irrevocable | ||||||
|  |       (except as stated in this section) patent license to make, have made, | ||||||
|  |       use, offer to sell, sell, import, and otherwise transfer the Work, | ||||||
|  |       where such license applies only to those patent claims licensable | ||||||
|  |       by such Contributor that are necessarily infringed by their | ||||||
|  |       Contribution(s) alone or by combination of their Contribution(s) | ||||||
|  |       with the Work to which such Contribution(s) was submitted. If You | ||||||
|  |       institute patent litigation against any entity (including a | ||||||
|  |       cross-claim or counterclaim in a lawsuit) alleging that the Work | ||||||
|  |       or a Contribution incorporated within the Work constitutes direct | ||||||
|  |       or contributory patent infringement, then any patent licenses | ||||||
|  |       granted to You under this License for that Work shall terminate | ||||||
|  |       as of the date such litigation is filed. | ||||||
|  |  | ||||||
|  |    4. Redistribution. You may reproduce and distribute copies of the | ||||||
|  |       Work or Derivative Works thereof in any medium, with or without | ||||||
|  |       modifications, and in Source or Object form, provided that You | ||||||
|  |       meet the following conditions: | ||||||
|  |  | ||||||
|  |       (a) You must give any other recipients of the Work or | ||||||
|  |           Derivative Works a copy of this License; and | ||||||
|  |  | ||||||
|  |       (b) You must cause any modified files to carry prominent notices | ||||||
|  |           stating that You changed the files; and | ||||||
|  |  | ||||||
|  |       (c) You must retain, in the Source form of any Derivative Works | ||||||
|  |           that You distribute, all copyright, patent, trademark, and | ||||||
|  |           attribution notices from the Source form of the Work, | ||||||
|  |           excluding those notices that do not pertain to any part of | ||||||
|  |           the Derivative Works; and | ||||||
|  |  | ||||||
|  |       (d) If the Work includes a "NOTICE" text file as part of its | ||||||
|  |           distribution, then any Derivative Works that You distribute must | ||||||
|  |           include a readable copy of the attribution notices contained | ||||||
|  |           within such NOTICE file, excluding those notices that do not | ||||||
|  |           pertain to any part of the Derivative Works, in at least one | ||||||
|  |           of the following places: within a NOTICE text file distributed | ||||||
|  |           as part of the Derivative Works; within the Source form or | ||||||
|  |           documentation, if provided along with the Derivative Works; or, | ||||||
|  |           within a display generated by the Derivative Works, if and | ||||||
|  |           wherever such third-party notices normally appear. The contents | ||||||
|  |           of the NOTICE file are for informational purposes only and | ||||||
|  |           do not modify the License. You may add Your own attribution | ||||||
|  |           notices within Derivative Works that You distribute, alongside | ||||||
|  |           or as an addendum to the NOTICE text from the Work, provided | ||||||
|  |           that such additional attribution notices cannot be construed | ||||||
|  |           as modifying the License. | ||||||
|  |  | ||||||
|  |       You may add Your own copyright statement to Your modifications and | ||||||
|  |       may provide additional or different license terms and conditions | ||||||
|  |       for use, reproduction, or distribution of Your modifications, or | ||||||
|  |       for any such Derivative Works as a whole, provided Your use, | ||||||
|  |       reproduction, and distribution of the Work otherwise complies with | ||||||
|  |       the conditions stated in this License. | ||||||
|  |  | ||||||
|  |    5. Submission of Contributions. Unless You explicitly state otherwise, | ||||||
|  |       any Contribution intentionally submitted for inclusion in the Work | ||||||
|  |       by You to the Licensor shall be under the terms and conditions of | ||||||
|  |       this License, without any additional terms or conditions. | ||||||
|  |       Notwithstanding the above, nothing herein shall supersede or modify | ||||||
|  |       the terms of any separate license agreement you may have executed | ||||||
|  |       with Licensor regarding such Contributions. | ||||||
|  |  | ||||||
|  |    6. Trademarks. This License does not grant permission to use the trade | ||||||
|  |       names, trademarks, service marks, or product names of the Licensor, | ||||||
|  |       except as required for reasonable and customary use in describing the | ||||||
|  |       origin of the Work and reproducing the content of the NOTICE file. | ||||||
|  |  | ||||||
|  |    7. Disclaimer of Warranty. Unless required by applicable law or | ||||||
|  |       agreed to in writing, Licensor provides the Work (and each | ||||||
|  |       Contributor provides its Contributions) on an "AS IS" BASIS, | ||||||
|  |       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or | ||||||
|  |       implied, including, without limitation, any warranties or conditions | ||||||
|  |       of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A | ||||||
|  |       PARTICULAR PURPOSE. You are solely responsible for determining the | ||||||
|  |       appropriateness of using or redistributing the Work and assume any | ||||||
|  |       risks associated with Your exercise of permissions under this License. | ||||||
|  |  | ||||||
|  |    8. Limitation of Liability. In no event and under no legal theory, | ||||||
|  |       whether in tort (including negligence), contract, or otherwise, | ||||||
|  |       unless required by applicable law (such as deliberate and grossly | ||||||
|  |       negligent acts) or agreed to in writing, shall any Contributor be | ||||||
|  |       liable to You for damages, including any direct, indirect, special, | ||||||
|  |       incidental, or consequential damages of any character arising as a | ||||||
|  |       result of this License or out of the use or inability to use the | ||||||
|  |       Work (including but not limited to damages for loss of goodwill, | ||||||
|  |       work stoppage, computer failure or malfunction, or any and all | ||||||
|  |       other commercial damages or losses), even if such Contributor | ||||||
|  |       has been advised of the possibility of such damages. | ||||||
|  |  | ||||||
|  |    9. Accepting Warranty or Additional Liability. While redistributing | ||||||
|  |       the Work or Derivative Works thereof, You may choose to offer, | ||||||
|  |       and charge a fee for, acceptance of support, warranty, indemnity, | ||||||
|  |       or other liability obligations and/or rights consistent with this | ||||||
|  |       License. However, in accepting such obligations, You may act only | ||||||
|  |       on Your own behalf and on Your sole responsibility, not on behalf | ||||||
|  |       of any other Contributor, and only if You agree to indemnify, | ||||||
|  |       defend, and hold each Contributor harmless for any liability | ||||||
|  |       incurred by, or claims asserted against, such Contributor by reason | ||||||
|  |       of your accepting any such warranty or additional liability. | ||||||
|  |  | ||||||
|  |    END OF TERMS AND CONDITIONS | ||||||
|  |  | ||||||
|  |    APPENDIX: How to apply the Apache License to your work. | ||||||
|  |  | ||||||
|  |       To apply the Apache License to your work, attach the following | ||||||
|  |       boilerplate notice, with the fields enclosed by brackets "[]" | ||||||
|  |       replaced with your own identifying information. (Don't include | ||||||
|  |       the brackets!)  The text should be enclosed in the appropriate | ||||||
|  |       comment syntax for the file format. We also recommend that a | ||||||
|  |       file or class name and description of purpose be included on the | ||||||
|  |       same "printed page" as the copyright notice for easier | ||||||
|  |       identification within third-party archives. | ||||||
|  |  | ||||||
|  |    Copyright 2025 NeoMovies | ||||||
|  |  | ||||||
|  |    Licensed under the Apache License, Version 2.0 (the "License"); | ||||||
|  |    you may not use this file except in compliance with the License. | ||||||
|  |    You may obtain a copy of the License at | ||||||
|  |  | ||||||
|  |        http://www.apache.org/licenses/LICENSE-2.0 | ||||||
|  |  | ||||||
|  |    Unless required by applicable law or agreed to in writing, software | ||||||
|  |    distributed under the License is distributed on an "AS IS" BASIS, | ||||||
|  |    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||
|  |    See the License for the specific language governing permissions and | ||||||
|  |    limitations under the License. | ||||||
							
								
								
									
										319
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										319
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,87 +1,272 @@ | |||||||
| # Neo Movies API | # Neo Movies API | ||||||
|  |  | ||||||
| API для поиска фильмов и сериалов с поддержкой русского языка. | REST API для поиска и получения информации о фильмах, использующий TMDB API. | ||||||
|  |  | ||||||
| ## Деплой на AlwaysData | ## Особенности | ||||||
|  |  | ||||||
| 1. Создайте аккаунт на [AlwaysData](https://www.alwaysdata.com) | - Поиск фильмов | ||||||
|  | - Информация о фильмах | ||||||
|  | - Популярные фильмы | ||||||
|  | - Топ рейтинговые фильмы | ||||||
|  | - Предстоящие фильмы | ||||||
|  | - Swagger документация | ||||||
|  | - Поддержка русского языка | ||||||
|  |  | ||||||
| 2. Настройте SSH ключ: | ## 🛠 Быстрый старт | ||||||
|    ```bash |  | ||||||
|    # Создайте SSH ключ если его нет |  | ||||||
|    ssh-keygen -t rsa -b 4096 |  | ||||||
|  |  | ||||||
|    # Скопируйте публичный ключ | ### Локальная разработка | ||||||
|    cat ~/.ssh/id_rsa.pub |  | ||||||
|    ``` |  | ||||||
|    Добавьте ключ в настройках AlwaysData (SSH Keys) |  | ||||||
|  |  | ||||||
| 3. Подключитесь по SSH: | 1. **Клонирование репозитория** | ||||||
|    ```bash | ```bash | ||||||
|    # Замените username на ваш логин | git clone https://gitlab.com/foxixus/neomovies-api.git | ||||||
|    ssh username@ssh-username.alwaysdata.net | cd neomovies-api | ||||||
|    ``` | ``` | ||||||
|  |  | ||||||
| 4. Установите Go: | 2. **Создание .env файла** | ||||||
|    ```bash | ```bash | ||||||
|    # Создайте директорию для Go | cp .env.example .env | ||||||
|    mkdir -p $HOME/go/bin | # Заполните необходимые переменные | ||||||
|  | ``` | ||||||
|  |  | ||||||
|    # Скачайте и установите Go | 3. **Установка зависимостей** | ||||||
|    wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz |  | ||||||
|    tar -C $HOME -xzf go1.21.5.linux-amd64.tar.gz |  | ||||||
|     |  | ||||||
|    # Добавьте Go в PATH |  | ||||||
|    echo 'export PATH=$HOME/go/bin:$HOME/go/bin:$PATH' >> ~/.bashrc |  | ||||||
|    source ~/.bashrc |  | ||||||
|    ``` |  | ||||||
|  |  | ||||||
| 5. Клонируйте репозиторий: |  | ||||||
|    ```bash |  | ||||||
|    git clone https://github.com/ваш-username/neomovies-api.git |  | ||||||
|    cd neomovies-api |  | ||||||
|    ``` |  | ||||||
|  |  | ||||||
| 6. Соберите приложение: |  | ||||||
|    ```bash |  | ||||||
|    chmod +x build.sh |  | ||||||
|    ./build.sh |  | ||||||
|    ``` |  | ||||||
|  |  | ||||||
| 7. Настройте сервис в панели AlwaysData: |  | ||||||
|    - Type: Site |  | ||||||
|    - Name: neomovies-api |  | ||||||
|    - Address: api.your-name.alwaysdata.net |  | ||||||
|    - Command: $HOME/neomovies-api/run.sh |  | ||||||
|    - Working directory: $HOME/neomovies-api |  | ||||||
|     |  | ||||||
| 8. Добавьте переменные окружения: |  | ||||||
|    - `TMDB_ACCESS_TOKEN`: Ваш токен TMDB API |  | ||||||
|    - `PORT`: 8080 (или порт по умолчанию) |  | ||||||
|  |  | ||||||
| После деплоя ваше API будет доступно по адресу: https://api.your-name.alwaysdata.net |  | ||||||
|  |  | ||||||
| ## Локальная разработка |  | ||||||
|  |  | ||||||
| 1. Установите зависимости: |  | ||||||
| ```bash | ```bash | ||||||
| go mod download | go mod download | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| 2. Запустите сервер: | 4. **Запуск** | ||||||
| ```bash | ```bash | ||||||
| go run main.go | go run main.go | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| API будет доступно по адресу: http://localhost:8080 | API будет доступен на `http://localhost:3000` | ||||||
|  |  | ||||||
| ## API Endpoints | ### Деплой на Vercel | ||||||
|  |  | ||||||
| - `GET /movies/search` - Поиск фильмов | 1. **Подключите репозиторий к Vercel** | ||||||
| - `GET /movies/popular` - Популярные фильмы | 2. **Настройте переменные окружения** (см. список ниже) | ||||||
| - `GET /movies/top-rated` - Лучшие фильмы | 3. **Деплой произойдет автоматически** | ||||||
| - `GET /movies/upcoming` - Предстоящие фильмы |  | ||||||
| - `GET /movies/:id` - Информация о фильме |  | ||||||
| - `GET /health` - Проверка работоспособности API |  | ||||||
|  |  | ||||||
| Полная документация API доступна по адресу: `/swagger/index.html` | ## ⚙️ Переменные окружения | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Обязательные | ||||||
|  | MONGO_URI= | ||||||
|  | MONGO_DB_NAME=database | ||||||
|  | TMDB_ACCESS_TOKEN= | ||||||
|  | JWT_SECRET= | ||||||
|  |  | ||||||
|  | # Сервис | ||||||
|  | PORT=3000 | ||||||
|  | BASE_URL=http://localhost:3000 | ||||||
|  | NODE_ENV=development | ||||||
|  |  | ||||||
|  | # Email (Gmail) | ||||||
|  | GMAIL_USER= | ||||||
|  | GMAIL_APP_PASSWORD= | ||||||
|  |  | ||||||
|  | # Плееры | ||||||
|  | LUMEX_URL= | ||||||
|  | ALLOHA_TOKEN= | ||||||
|  | VIBIX_TOKEN= | ||||||
|  |  | ||||||
|  | # Торренты (RedAPI) | ||||||
|  | REDAPI_BASE_URL=http://redapi.cfhttp.top | ||||||
|  | REDAPI_KEY= | ||||||
|  |  | ||||||
|  | # Google OAuth | ||||||
|  | GOOGLE_CLIENT_ID= | ||||||
|  | GOOGLE_CLIENT_SECRET= | ||||||
|  | GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## 📋 API Endpoints | ||||||
|  |  | ||||||
|  | ### 🔓 Публичные маршруты | ||||||
|  |  | ||||||
|  | ```http | ||||||
|  | # Система | ||||||
|  | GET  /api/v1/health                          # Проверка состояния | ||||||
|  |  | ||||||
|  | # Аутентификация | ||||||
|  | POST /api/v1/auth/register                   # Регистрация (отправка кода) | ||||||
|  | POST /api/v1/auth/verify                     # Подтверждение email кодом | ||||||
|  | POST /api/v1/auth/resend-code               # Повторная отправка кода | ||||||
|  | POST /api/v1/auth/login                      # Авторизация | ||||||
|  | GET  /api/v1/auth/google/login               # Начало авторизации через Google (redirect) | ||||||
|  | GET  /api/v1/auth/google/callback            # Коллбек Google OAuth (возвращает JWT) | ||||||
|  |  | ||||||
|  | # Поиск и категории | ||||||
|  | GET  /search/multi                           # Мультипоиск | ||||||
|  | GET  /api/v1/categories                      # Список категорий | ||||||
|  | GET  /api/v1/categories/{id}/movies          # Фильмы по категории | ||||||
|  |  | ||||||
|  | # Фильмы | ||||||
|  | GET  /api/v1/movies/search                   # Поиск фильмов | ||||||
|  | GET  /api/v1/movies/popular                  # Популярные | ||||||
|  | GET  /api/v1/movies/top-rated                # Топ-рейтинговые | ||||||
|  | GET  /api/v1/movies/upcoming                 # Предстоящие | ||||||
|  | GET  /api/v1/movies/now-playing              # В прокате | ||||||
|  | GET  /api/v1/movies/{id}                     # Детали фильма | ||||||
|  | GET  /api/v1/movies/{id}/recommendations     # Рекомендации | ||||||
|  | GET  /api/v1/movies/{id}/similar             # Похожие | ||||||
|  |  | ||||||
|  | # Сериалы | ||||||
|  | GET  /api/v1/tv/search                       # Поиск сериалов | ||||||
|  | GET  /api/v1/tv/popular                      # Популярные | ||||||
|  | GET  /api/v1/tv/top-rated                    # Топ-рейтинговые | ||||||
|  | GET  /api/v1/tv/on-the-air                   # В эфире | ||||||
|  | GET  /api/v1/tv/airing-today                 # Сегодня в эфире | ||||||
|  | GET  /api/v1/tv/{id}                         # Детали сериала | ||||||
|  | GET  /api/v1/tv/{id}/recommendations         # Рекомендации | ||||||
|  | GET  /api/v1/tv/{id}/similar                 # Похожие | ||||||
|  |  | ||||||
|  | # Плееры | ||||||
|  | GET  /api/v1/players/alloha/{imdb_id}          # Alloha плеер по IMDb ID | ||||||
|  | GET  /api/v1/players/lumex/{imdb_id}           # Lumex плеер по IMDb ID | ||||||
|  | GET  /api/v1/players/vibix/{imdb_id}           # Vibix плеер по IMDb ID | ||||||
|  |  | ||||||
|  | # Торренты | ||||||
|  | GET  /api/v1/torrents/search/{imdbId}        # Поиск торрентов | ||||||
|  |  | ||||||
|  | # Реакции (публичные) | ||||||
|  | GET  /api/v1/reactions/{mediaType}/{mediaId}/counts    # Счетчики реакций | ||||||
|  |  | ||||||
|  | # Изображения | ||||||
|  | GET  /api/v1/images/{size}/{path}            # Прокси TMDB изображений | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### 🔒 Приватные маршруты (требуют JWT) | ||||||
|  |  | ||||||
|  | ```http | ||||||
|  | # Профиль | ||||||
|  | GET  /api/v1/auth/profile                    # Профиль пользователя | ||||||
|  | PUT  /api/v1/auth/profile                    # Обновление профиля | ||||||
|  |  | ||||||
|  | # Избранное | ||||||
|  | GET  /api/v1/favorites                       # Список избранного | ||||||
|  | POST /api/v1/favorites/{id}                  # Добавить в избранное | ||||||
|  | DELETE /api/v1/favorites/{id}                # Удалить из избранного | ||||||
|  |  | ||||||
|  | # Реакции (приватные) | ||||||
|  | GET  /api/v1/reactions/{mediaType}/{mediaId}/my-reaction # Моя реакция | ||||||
|  | POST /api/v1/reactions/{mediaType}/{mediaId}           # Установить реакцию | ||||||
|  | DELETE /api/v1/reactions/{mediaType}/{mediaId}         # Удалить реакцию | ||||||
|  | GET  /api/v1/reactions/my                              # Все мои реакции | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## 📖 Примеры использования | ||||||
|  |  | ||||||
|  | ### Регистрация и верификация | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # 1. Регистрация | ||||||
|  | curl -X POST https://api.neomovies.ru/api/v1/auth/register \ | ||||||
|  |   -H "Content-Type: application/json" \ | ||||||
|  |   -d '{ | ||||||
|  |     "email": "user@example.com", | ||||||
|  |     "password": "password123", | ||||||
|  |     "name": "John Doe" | ||||||
|  |   }' | ||||||
|  |  | ||||||
|  | # Ответ: {"success": true, "message": "Registered. Check email for verification code."} | ||||||
|  |  | ||||||
|  | # 2. Подтверждение email (код из письма) | ||||||
|  | curl -X POST https://api.neomovies.ru/api/v1/auth/verify \ | ||||||
|  |   -H "Content-Type: application/json" \ | ||||||
|  |   -d '{ | ||||||
|  |     "email": "user@example.com", | ||||||
|  |     "code": "123456" | ||||||
|  |   }' | ||||||
|  |  | ||||||
|  | # 3. Авторизация | ||||||
|  | curl -X POST https://api.neomovies.ru/api/v1/auth/login \ | ||||||
|  |   -H "Content-Type: application/json" \ | ||||||
|  |   -d '{ | ||||||
|  |     "email": "user@example.com", | ||||||
|  |     "password": "password123" | ||||||
|  |   }' | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Поиск фильмов | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Поиск фильмов | ||||||
|  | curl "https://api.neomovies.ru/api/v1/movies/search?query=marvel&page=1" | ||||||
|  |  | ||||||
|  | # Детали фильма | ||||||
|  | curl "https://api.neomovies.ru/api/v1/movies/550" | ||||||
|  |  | ||||||
|  | # Добавить в избранное (с JWT токеном) | ||||||
|  | curl -X POST https://api.neomovies.ru/api/v1/favorites/550 \ | ||||||
|  |   -H "Authorization: Bearer YOUR_JWT_TOKEN" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ### Поиск торрентов | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Поиск торрентов для фильма "Побег из Шоушенка" | ||||||
|  | curl "https://api.neomovies.ru/api/v1/torrents/search/tt0111161?type=movie&quality=1080p" | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## 🎨 Документация API | ||||||
|  |  | ||||||
|  | Интерактивная документация доступна по адресу: | ||||||
|  |  | ||||||
|  | **🔗 https://api.neomovies.ru/** | ||||||
|  |  | ||||||
|  | ## ☁️ Деплой на Vercel | ||||||
|  |  | ||||||
|  | 1. **Подключите репозиторий к Vercel** | ||||||
|  | 2. **Настройте Environment Variables в Vercel Dashboard:** | ||||||
|  | 3. **Деплой автоматически запустится!** | ||||||
|  |  | ||||||
|  | ## 🏗 Архитектура | ||||||
|  |  | ||||||
|  | ``` | ||||||
|  | ├── main.go                 # Точка входа приложения | ||||||
|  | ├── api/ | ||||||
|  | │   └── index.go           # Vercel serverless handler | ||||||
|  | ├── pkg/                   # Публичные пакеты (совместимо с Vercel) | ||||||
|  | │   ├── config/           # Конфигурация с поддержкой альтернативных env vars | ||||||
|  | │   ├── database/         # Подключение к MongoDB | ||||||
|  | │   ├── middleware/       # JWT, CORS, логирование | ||||||
|  | │   ├── models/          # Структуры данных | ||||||
|  | │   ├── services/        # Бизнес-логика | ||||||
|  | │   └── handlers/        # HTTP обработчики | ||||||
|  | ├── vercel.json          # Конфигурация Vercel | ||||||
|  | └── go.mod              # Go модули | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | ## 🔧 Технологии | ||||||
|  |  | ||||||
|  | - **Go 1.21** - основной язык | ||||||
|  | - **Gorilla Mux** - HTTP роутер | ||||||
|  | - **MongoDB** - база данных | ||||||
|  | - **JWT** - аутентификация | ||||||
|  | - **TMDB API** - данные о фильмах | ||||||
|  | - **Gmail SMTP** - email уведомления | ||||||
|  | - **Vercel** - деплой и хостинг | ||||||
|  |  | ||||||
|  | ## 🚀 Производительность | ||||||
|  |  | ||||||
|  | По сравнению с Node.js версией: | ||||||
|  | - **3x быстрее** обработка запросов | ||||||
|  | - **50% меньше** потребление памяти | ||||||
|  | - **Конкурентность** благодаря горутинам | ||||||
|  | - **Типобезопасность** предотвращает ошибки | ||||||
|  |  | ||||||
|  | ## 🤝 Contribution | ||||||
|  |  | ||||||
|  | 1. Форкните репозиторий | ||||||
|  | 2. Создайте feature-ветку (`git checkout -b feature/amazing-feature`) | ||||||
|  | 3. Коммитьте изменения (`git commit -m 'Add amazing feature'`) | ||||||
|  | 4. Пушните в ветку (`git push origin feature/amazing-feature`) | ||||||
|  | 5. Откройте Pull Request | ||||||
|  |  | ||||||
|  | ## 📄 Лицензия | ||||||
|  |  | ||||||
|  | Apache License 2.0 - подробности в файле [LICENSE](LICENSE) | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | Made with <3 by Foxix | ||||||
							
								
								
									
										160
									
								
								api/index.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								api/index.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,160 @@ | |||||||
|  | package handler | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"log" | ||||||
|  | 	"net/http" | ||||||
|  | 	"sync" | ||||||
|  |  | ||||||
|  | 	"github.com/gorilla/handlers" | ||||||
|  | 	"github.com/gorilla/mux" | ||||||
|  | 	"github.com/joho/godotenv" | ||||||
|  | 	"go.mongodb.org/mongo-driver/mongo" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/config" | ||||||
|  | 	"neomovies-api/pkg/database" | ||||||
|  | 	handlersPkg "neomovies-api/pkg/handlers" | ||||||
|  | 	"neomovies-api/pkg/middleware" | ||||||
|  | 	"neomovies-api/pkg/services" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | var ( | ||||||
|  | 	globalDB  *mongo.Database | ||||||
|  | 	globalCfg *config.Config | ||||||
|  | 	initOnce  sync.Once | ||||||
|  | 	initError error | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func initializeApp() { | ||||||
|  | 	if err := godotenv.Load(); err != nil { | ||||||
|  | 		_ = err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	globalCfg = config.New() | ||||||
|  |  | ||||||
|  | 	var err error | ||||||
|  | 	globalDB, err = database.Connect(globalCfg.MongoURI, globalCfg.MongoDBName) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("Failed to connect to database: %v", err) | ||||||
|  | 		initError = err | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	log.Println("Successfully connected to database") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func Handler(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	initOnce.Do(initializeApp) | ||||||
|  |  | ||||||
|  | 	if initError != nil { | ||||||
|  | 		log.Printf("Initialization error: %v", initError) | ||||||
|  | 		http.Error(w, "Application initialization failed: "+initError.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	tmdbService := services.NewTMDBService(globalCfg.TMDBAccessToken) | ||||||
|  | 	emailService := services.NewEmailService(globalCfg) | ||||||
|  | 	authService := services.NewAuthService(globalDB, globalCfg.JWTSecret, emailService, globalCfg.BaseURL, globalCfg.GoogleClientID, globalCfg.GoogleClientSecret, globalCfg.GoogleRedirectURL, globalCfg.FrontendURL) | ||||||
|  |  | ||||||
|  | 	movieService := services.NewMovieService(globalDB, tmdbService) | ||||||
|  | 	tvService := services.NewTVService(globalDB, tmdbService) | ||||||
|  | 	favoritesService := services.NewFavoritesService(globalDB, tmdbService) | ||||||
|  | 	torrentService := services.NewTorrentServiceWithConfig(globalCfg.RedAPIBaseURL, globalCfg.RedAPIKey) | ||||||
|  | 	reactionsService := services.NewReactionsService(globalDB) | ||||||
|  |  | ||||||
|  | 	authHandler := handlersPkg.NewAuthHandler(authService) | ||||||
|  | 	movieHandler := handlersPkg.NewMovieHandler(movieService) | ||||||
|  | 	tvHandler := handlersPkg.NewTVHandler(tvService) | ||||||
|  | 	favoritesHandler := handlersPkg.NewFavoritesHandler(favoritesService, globalCfg) | ||||||
|  | 	docsHandler := handlersPkg.NewDocsHandler() | ||||||
|  | 	searchHandler := handlersPkg.NewSearchHandler(tmdbService) | ||||||
|  | 	categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService) | ||||||
|  | 	playersHandler := handlersPkg.NewPlayersHandler(globalCfg) | ||||||
|  | 	torrentsHandler := handlersPkg.NewTorrentsHandler(torrentService, tmdbService) | ||||||
|  | 	reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService) | ||||||
|  | 	imagesHandler := handlersPkg.NewImagesHandler() | ||||||
|  |  | ||||||
|  | 	router := mux.NewRouter() | ||||||
|  |  | ||||||
|  | 	router.HandleFunc("/", docsHandler.ServeDocs).Methods("GET") | ||||||
|  | 	router.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api := router.PathPrefix("/api/v1").Subrouter() | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/health", handlersPkg.HealthCheck).Methods("GET") | ||||||
|  | 	api.HandleFunc("/auth/register", authHandler.Register).Methods("POST") | ||||||
|  | 	api.HandleFunc("/auth/login", authHandler.Login).Methods("POST") | ||||||
|  | 	api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST") | ||||||
|  | 	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("/search/multi", searchHandler.MultiSearch).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET") | ||||||
|  | 	api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET") | ||||||
|  | 	api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET") | ||||||
|  | 	api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") | ||||||
|  | 	api.HandleFunc("/players/rgshows/{tmdb_id}", playersHandler.GetRgShowsPlayer).Methods("GET") | ||||||
|  | 	api.HandleFunc("/players/rgshows/{tmdb_id}/{season}/{episode}", playersHandler.GetRgShowsTVPlayer).Methods("GET") | ||||||
|  | 	api.HandleFunc("/players/iframevideo/{kinopoisk_id}/{imdb_id}", playersHandler.GetIframeVideoPlayer).Methods("GET") | ||||||
|  | 	api.HandleFunc("/stream/{provider}/{tmdb_id}", playersHandler.GetStreamAPI).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") | ||||||
|  | 	api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") | ||||||
|  | 	api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET") | ||||||
|  | 	api.HandleFunc("/torrents/anime", torrentsHandler.SearchAnime).Methods("GET") | ||||||
|  | 	api.HandleFunc("/torrents/seasons", torrentsHandler.GetAvailableSeasons).Methods("GET") | ||||||
|  | 	api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/upcoming", movieHandler.Upcoming).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/now-playing", movieHandler.NowPlaying).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/{id}", movieHandler.GetByID).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/{id}/recommendations", movieHandler.GetRecommendations).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/{id}/similar", movieHandler.GetSimilar).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/top-rated", tvHandler.TopRated).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/on-the-air", tvHandler.OnTheAir).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/airing-today", tvHandler.AiringToday).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/{id}", tvHandler.GetByID).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/{id}/recommendations", tvHandler.GetRecommendations).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/{id}/similar", tvHandler.GetSimilar).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET") | ||||||
|  |  | ||||||
|  | 	protected := api.PathPrefix("").Subrouter() | ||||||
|  | 	protected.Use(middleware.JWTAuth(globalCfg.JWTSecret)) | ||||||
|  |  | ||||||
|  | 	protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET") | ||||||
|  | 	protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST") | ||||||
|  | 	protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE") | ||||||
|  | 	protected.HandleFunc("/favorites/{id}/check", favoritesHandler.CheckIsFavorite).Methods("GET") | ||||||
|  |  | ||||||
|  | 	protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET") | ||||||
|  | 	protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") | ||||||
|  | 	protected.HandleFunc("/auth/profile", authHandler.DeleteAccount).Methods("DELETE") | ||||||
|  |  | ||||||
|  | 	protected.HandleFunc("/reactions/{mediaType}/{mediaId}/my-reaction", reactionsHandler.GetMyReaction).Methods("GET") | ||||||
|  | 	protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.SetReaction).Methods("POST") | ||||||
|  | 	protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE") | ||||||
|  | 	protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET") | ||||||
|  |  | ||||||
|  | 	corsHandler := handlers.CORS( | ||||||
|  | 		handlers.AllowedOrigins([]string{"*"}), | ||||||
|  | 		handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), | ||||||
|  | 		handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}), | ||||||
|  | 		handlers.AllowCredentials(), | ||||||
|  | 	) | ||||||
|  |  | ||||||
|  | 	corsHandler(router).ServeHTTP(w, r) | ||||||
|  | } | ||||||
							
								
								
									
										7
									
								
								build.sh
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								build.sh
									
									
									
									
									
								
							| @@ -1,7 +0,0 @@ | |||||||
| #!/bin/bash |  | ||||||
|  |  | ||||||
| # Переходим в директорию с приложением |  | ||||||
| cd "$HOME/neomovies-api" |  | ||||||
|  |  | ||||||
| # Собираем приложение |  | ||||||
| go build -o app |  | ||||||
							
								
								
									
										806
									
								
								docs/docs.go
									
									
									
									
									
								
							
							
						
						
									
										806
									
								
								docs/docs.go
									
									
									
									
									
								
							| @@ -1,806 +0,0 @@ | |||||||
| // Package docs Code generated by swaggo/swag. DO NOT EDIT |  | ||||||
| package docs |  | ||||||
|  |  | ||||||
| import "github.com/swaggo/swag" |  | ||||||
|  |  | ||||||
| const docTemplate = `{ |  | ||||||
|     "schemes": {{ marshal .Schemes }}, |  | ||||||
|     "swagger": "2.0", |  | ||||||
|     "info": { |  | ||||||
|         "description": "{{escape .Description}}", |  | ||||||
|         "title": "{{.Title}}", |  | ||||||
|         "contact": {}, |  | ||||||
|         "version": "{{.Version}}" |  | ||||||
|     }, |  | ||||||
|     "host": "{{.Host}}", |  | ||||||
|     "basePath": "{{.BasePath}}", |  | ||||||
|     "paths": { |  | ||||||
|         "/bridge/tmdb/discover/movie": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of movies based on filters", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Discover movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.TMDBMoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/discover/tv": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of TV shows based on filters", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Discover TV shows", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.TMDBMoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/movie/popular": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of popular movies directly from TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB popular movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.TMDBMoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/movie/top_rated": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of top rated movies directly from TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB top rated movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.TMDBMoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/movie/upcoming": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of upcoming movies directly from TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB upcoming movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.TMDBMoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/movie/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get detailed information about a specific movie directly from TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB movie details", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Movie ID", |  | ||||||
|                         "name": "id", |  | ||||||
|                         "in": "path", |  | ||||||
|                         "required": true |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/tmdb.Movie" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/movie/{id}/external_ids": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get external IDs (IMDb, Facebook, Instagram, Twitter) for a specific movie", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB movie external IDs", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Movie ID", |  | ||||||
|                         "name": "id", |  | ||||||
|                         "in": "path", |  | ||||||
|                         "required": true |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/tmdb.ExternalIDs" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/search/movie": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Search for movies directly in TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Search TMDB movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "string", |  | ||||||
|                         "description": "Search query", |  | ||||||
|                         "name": "query", |  | ||||||
|                         "in": "query", |  | ||||||
|                         "required": true |  | ||||||
|                     }, |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/tmdb.MoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/search/tv": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Search for TV shows directly in TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Search TMDB TV shows", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "string", |  | ||||||
|                         "description": "Search query", |  | ||||||
|                         "name": "query", |  | ||||||
|                         "in": "query", |  | ||||||
|                         "required": true |  | ||||||
|                     }, |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/tmdb.TVSearchResults" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/tv/{id}/external_ids": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get external IDs (IMDb, Facebook, Instagram, Twitter) for a specific TV show", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB TV show external IDs", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "TV Show ID", |  | ||||||
|                         "name": "id", |  | ||||||
|                         "in": "path", |  | ||||||
|                         "required": true |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/tmdb.ExternalIDs" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/movies/popular": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of popular movies", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "movies" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get popular movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.MoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/movies/search": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Search for movies", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "movies" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Search movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "string", |  | ||||||
|                         "description": "Search query", |  | ||||||
|                         "name": "query", |  | ||||||
|                         "in": "query", |  | ||||||
|                         "required": true |  | ||||||
|                     }, |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.MoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/movies/top-rated": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of top rated movies", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "movies" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get top rated movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.MoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/movies/upcoming": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of upcoming movies", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "movies" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get upcoming movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.MoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/movies/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get detailed information about a specific movie", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "movies" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get movie details", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Movie ID", |  | ||||||
|                         "name": "id", |  | ||||||
|                         "in": "path", |  | ||||||
|                         "required": true |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.MovieDetails" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     }, |  | ||||||
|     "definitions": { |  | ||||||
|         "api.Genre": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "name": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "api.Movie": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "backdrop_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "genres": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/api.Genre" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "overview": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "poster_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "release_date": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "title": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "vote_average": { |  | ||||||
|                     "type": "number" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "api.MovieDetails": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "backdrop_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "budget": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "genres": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/api.Genre" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "overview": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "poster_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "release_date": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "revenue": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "runtime": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "status": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "tagline": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "title": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "vote_average": { |  | ||||||
|                     "type": "number" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "api.MoviesResponse": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "page": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "results": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/api.Movie" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "total_pages": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "total_results": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "api.TMDBMoviesResponse": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "page": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "results": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/api.Movie" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "total_pages": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "total_results": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.ExternalIDs": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "facebook_id": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "imdb_id": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "instagram_id": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "twitter_id": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.Genre": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "name": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.Movie": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "backdrop_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "genres": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/tmdb.Genre" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "overview": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "poster_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "release_date": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "title": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "vote_average": { |  | ||||||
|                     "type": "number" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.MoviesResponse": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "page": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "results": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/tmdb.Movie" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "total_pages": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "total_results": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.TV": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "backdrop_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "first_air_date": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "genre_ids": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "type": "integer" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "name": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "original_language": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "original_name": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "overview": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "popularity": { |  | ||||||
|                     "type": "number" |  | ||||||
|                 }, |  | ||||||
|                 "poster_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "vote_average": { |  | ||||||
|                     "type": "number" |  | ||||||
|                 }, |  | ||||||
|                 "vote_count": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.TVSearchResults": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "page": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "results": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/tmdb.TV" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "total_pages": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "total_results": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| }` |  | ||||||
|  |  | ||||||
| // SwaggerInfo holds exported Swagger Info so clients can modify it |  | ||||||
| var SwaggerInfo = &swag.Spec{ |  | ||||||
| 	Version:          "1.0", |  | ||||||
| 	Host:             "localhost:8080", |  | ||||||
| 	BasePath:         "/", |  | ||||||
| 	Schemes:          []string{}, |  | ||||||
| 	Title:            "Neo Movies API", |  | ||||||
| 	Description:      "API для работы с фильмами", |  | ||||||
| 	InfoInstanceName: "swagger", |  | ||||||
| 	SwaggerTemplate:  docTemplate, |  | ||||||
| 	LeftDelim:        "{{", |  | ||||||
| 	RightDelim:       "}}", |  | ||||||
| } |  | ||||||
|  |  | ||||||
| func init() { |  | ||||||
| 	swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) |  | ||||||
| } |  | ||||||
| @@ -1,782 +0,0 @@ | |||||||
| { |  | ||||||
|     "swagger": "2.0", |  | ||||||
|     "info": { |  | ||||||
|         "description": "API для работы с фильмами", |  | ||||||
|         "title": "Neo Movies API", |  | ||||||
|         "contact": {}, |  | ||||||
|         "version": "1.0" |  | ||||||
|     }, |  | ||||||
|     "host": "localhost:8080", |  | ||||||
|     "basePath": "/", |  | ||||||
|     "paths": { |  | ||||||
|         "/bridge/tmdb/discover/movie": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of movies based on filters", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Discover movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.TMDBMoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/discover/tv": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of TV shows based on filters", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Discover TV shows", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.TMDBMoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/movie/popular": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of popular movies directly from TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB popular movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.TMDBMoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/movie/top_rated": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of top rated movies directly from TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB top rated movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.TMDBMoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/movie/upcoming": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of upcoming movies directly from TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB upcoming movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.TMDBMoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/movie/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get detailed information about a specific movie directly from TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB movie details", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Movie ID", |  | ||||||
|                         "name": "id", |  | ||||||
|                         "in": "path", |  | ||||||
|                         "required": true |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/tmdb.Movie" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/movie/{id}/external_ids": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get external IDs (IMDb, Facebook, Instagram, Twitter) for a specific movie", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB movie external IDs", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Movie ID", |  | ||||||
|                         "name": "id", |  | ||||||
|                         "in": "path", |  | ||||||
|                         "required": true |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/tmdb.ExternalIDs" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/search/movie": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Search for movies directly in TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Search TMDB movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "string", |  | ||||||
|                         "description": "Search query", |  | ||||||
|                         "name": "query", |  | ||||||
|                         "in": "query", |  | ||||||
|                         "required": true |  | ||||||
|                     }, |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/tmdb.MoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/search/tv": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Search for TV shows directly in TMDB", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Search TMDB TV shows", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "string", |  | ||||||
|                         "description": "Search query", |  | ||||||
|                         "name": "query", |  | ||||||
|                         "in": "query", |  | ||||||
|                         "required": true |  | ||||||
|                     }, |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/tmdb.TVSearchResults" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/bridge/tmdb/tv/{id}/external_ids": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get external IDs (IMDb, Facebook, Instagram, Twitter) for a specific TV show", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "tmdb" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get TMDB TV show external IDs", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "TV Show ID", |  | ||||||
|                         "name": "id", |  | ||||||
|                         "in": "path", |  | ||||||
|                         "required": true |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/tmdb.ExternalIDs" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/movies/popular": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of popular movies", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "movies" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get popular movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.MoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/movies/search": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Search for movies", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "movies" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Search movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "string", |  | ||||||
|                         "description": "Search query", |  | ||||||
|                         "name": "query", |  | ||||||
|                         "in": "query", |  | ||||||
|                         "required": true |  | ||||||
|                     }, |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.MoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/movies/top-rated": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of top rated movies", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "movies" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get top rated movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.MoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/movies/upcoming": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get a list of upcoming movies", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "movies" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get upcoming movies", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Page number (default: 1)", |  | ||||||
|                         "name": "page", |  | ||||||
|                         "in": "query" |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.MoviesResponse" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "/movies/{id}": { |  | ||||||
|             "get": { |  | ||||||
|                 "description": "Get detailed information about a specific movie", |  | ||||||
|                 "consumes": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "produces": [ |  | ||||||
|                     "application/json" |  | ||||||
|                 ], |  | ||||||
|                 "tags": [ |  | ||||||
|                     "movies" |  | ||||||
|                 ], |  | ||||||
|                 "summary": "Get movie details", |  | ||||||
|                 "parameters": [ |  | ||||||
|                     { |  | ||||||
|                         "type": "integer", |  | ||||||
|                         "description": "Movie ID", |  | ||||||
|                         "name": "id", |  | ||||||
|                         "in": "path", |  | ||||||
|                         "required": true |  | ||||||
|                     } |  | ||||||
|                 ], |  | ||||||
|                 "responses": { |  | ||||||
|                     "200": { |  | ||||||
|                         "description": "OK", |  | ||||||
|                         "schema": { |  | ||||||
|                             "$ref": "#/definitions/api.MovieDetails" |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     }, |  | ||||||
|     "definitions": { |  | ||||||
|         "api.Genre": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "name": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "api.Movie": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "backdrop_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "genres": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/api.Genre" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "overview": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "poster_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "release_date": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "title": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "vote_average": { |  | ||||||
|                     "type": "number" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "api.MovieDetails": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "backdrop_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "budget": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "genres": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/api.Genre" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "overview": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "poster_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "release_date": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "revenue": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "runtime": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "status": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "tagline": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "title": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "vote_average": { |  | ||||||
|                     "type": "number" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "api.MoviesResponse": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "page": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "results": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/api.Movie" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "total_pages": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "total_results": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "api.TMDBMoviesResponse": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "page": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "results": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/api.Movie" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "total_pages": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "total_results": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.ExternalIDs": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "facebook_id": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "imdb_id": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "instagram_id": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "twitter_id": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.Genre": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "name": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.Movie": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "backdrop_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "genres": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/tmdb.Genre" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "overview": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "poster_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "release_date": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "title": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "vote_average": { |  | ||||||
|                     "type": "number" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.MoviesResponse": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "page": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "results": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/tmdb.Movie" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "total_pages": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "total_results": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.TV": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "backdrop_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "first_air_date": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "genre_ids": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "type": "integer" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "id": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "name": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "original_language": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "original_name": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "overview": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "popularity": { |  | ||||||
|                     "type": "number" |  | ||||||
|                 }, |  | ||||||
|                 "poster_path": { |  | ||||||
|                     "type": "string" |  | ||||||
|                 }, |  | ||||||
|                 "vote_average": { |  | ||||||
|                     "type": "number" |  | ||||||
|                 }, |  | ||||||
|                 "vote_count": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|         "tmdb.TVSearchResults": { |  | ||||||
|             "type": "object", |  | ||||||
|             "properties": { |  | ||||||
|                 "page": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "results": { |  | ||||||
|                     "type": "array", |  | ||||||
|                     "items": { |  | ||||||
|                         "$ref": "#/definitions/tmdb.TV" |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 "total_pages": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 }, |  | ||||||
|                 "total_results": { |  | ||||||
|                     "type": "integer" |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,512 +0,0 @@ | |||||||
| basePath: / |  | ||||||
| definitions: |  | ||||||
|   api.Genre: |  | ||||||
|     properties: |  | ||||||
|       id: |  | ||||||
|         type: integer |  | ||||||
|       name: |  | ||||||
|         type: string |  | ||||||
|     type: object |  | ||||||
|   api.Movie: |  | ||||||
|     properties: |  | ||||||
|       backdrop_path: |  | ||||||
|         type: string |  | ||||||
|       genres: |  | ||||||
|         items: |  | ||||||
|           $ref: '#/definitions/api.Genre' |  | ||||||
|         type: array |  | ||||||
|       id: |  | ||||||
|         type: integer |  | ||||||
|       overview: |  | ||||||
|         type: string |  | ||||||
|       poster_path: |  | ||||||
|         type: string |  | ||||||
|       release_date: |  | ||||||
|         type: string |  | ||||||
|       title: |  | ||||||
|         type: string |  | ||||||
|       vote_average: |  | ||||||
|         type: number |  | ||||||
|     type: object |  | ||||||
|   api.MovieDetails: |  | ||||||
|     properties: |  | ||||||
|       backdrop_path: |  | ||||||
|         type: string |  | ||||||
|       budget: |  | ||||||
|         type: integer |  | ||||||
|       genres: |  | ||||||
|         items: |  | ||||||
|           $ref: '#/definitions/api.Genre' |  | ||||||
|         type: array |  | ||||||
|       id: |  | ||||||
|         type: integer |  | ||||||
|       overview: |  | ||||||
|         type: string |  | ||||||
|       poster_path: |  | ||||||
|         type: string |  | ||||||
|       release_date: |  | ||||||
|         type: string |  | ||||||
|       revenue: |  | ||||||
|         type: integer |  | ||||||
|       runtime: |  | ||||||
|         type: integer |  | ||||||
|       status: |  | ||||||
|         type: string |  | ||||||
|       tagline: |  | ||||||
|         type: string |  | ||||||
|       title: |  | ||||||
|         type: string |  | ||||||
|       vote_average: |  | ||||||
|         type: number |  | ||||||
|     type: object |  | ||||||
|   api.MoviesResponse: |  | ||||||
|     properties: |  | ||||||
|       page: |  | ||||||
|         type: integer |  | ||||||
|       results: |  | ||||||
|         items: |  | ||||||
|           $ref: '#/definitions/api.Movie' |  | ||||||
|         type: array |  | ||||||
|       total_pages: |  | ||||||
|         type: integer |  | ||||||
|       total_results: |  | ||||||
|         type: integer |  | ||||||
|     type: object |  | ||||||
|   api.TMDBMoviesResponse: |  | ||||||
|     properties: |  | ||||||
|       page: |  | ||||||
|         type: integer |  | ||||||
|       results: |  | ||||||
|         items: |  | ||||||
|           $ref: '#/definitions/api.Movie' |  | ||||||
|         type: array |  | ||||||
|       total_pages: |  | ||||||
|         type: integer |  | ||||||
|       total_results: |  | ||||||
|         type: integer |  | ||||||
|     type: object |  | ||||||
|   tmdb.ExternalIDs: |  | ||||||
|     properties: |  | ||||||
|       facebook_id: |  | ||||||
|         type: string |  | ||||||
|       id: |  | ||||||
|         type: integer |  | ||||||
|       imdb_id: |  | ||||||
|         type: string |  | ||||||
|       instagram_id: |  | ||||||
|         type: string |  | ||||||
|       twitter_id: |  | ||||||
|         type: string |  | ||||||
|     type: object |  | ||||||
|   tmdb.Genre: |  | ||||||
|     properties: |  | ||||||
|       id: |  | ||||||
|         type: integer |  | ||||||
|       name: |  | ||||||
|         type: string |  | ||||||
|     type: object |  | ||||||
|   tmdb.Movie: |  | ||||||
|     properties: |  | ||||||
|       backdrop_path: |  | ||||||
|         type: string |  | ||||||
|       genres: |  | ||||||
|         items: |  | ||||||
|           $ref: '#/definitions/tmdb.Genre' |  | ||||||
|         type: array |  | ||||||
|       id: |  | ||||||
|         type: integer |  | ||||||
|       overview: |  | ||||||
|         type: string |  | ||||||
|       poster_path: |  | ||||||
|         type: string |  | ||||||
|       release_date: |  | ||||||
|         type: string |  | ||||||
|       title: |  | ||||||
|         type: string |  | ||||||
|       vote_average: |  | ||||||
|         type: number |  | ||||||
|     type: object |  | ||||||
|   tmdb.MoviesResponse: |  | ||||||
|     properties: |  | ||||||
|       page: |  | ||||||
|         type: integer |  | ||||||
|       results: |  | ||||||
|         items: |  | ||||||
|           $ref: '#/definitions/tmdb.Movie' |  | ||||||
|         type: array |  | ||||||
|       total_pages: |  | ||||||
|         type: integer |  | ||||||
|       total_results: |  | ||||||
|         type: integer |  | ||||||
|     type: object |  | ||||||
|   tmdb.TV: |  | ||||||
|     properties: |  | ||||||
|       backdrop_path: |  | ||||||
|         type: string |  | ||||||
|       first_air_date: |  | ||||||
|         type: string |  | ||||||
|       genre_ids: |  | ||||||
|         items: |  | ||||||
|           type: integer |  | ||||||
|         type: array |  | ||||||
|       id: |  | ||||||
|         type: integer |  | ||||||
|       name: |  | ||||||
|         type: string |  | ||||||
|       original_language: |  | ||||||
|         type: string |  | ||||||
|       original_name: |  | ||||||
|         type: string |  | ||||||
|       overview: |  | ||||||
|         type: string |  | ||||||
|       popularity: |  | ||||||
|         type: number |  | ||||||
|       poster_path: |  | ||||||
|         type: string |  | ||||||
|       vote_average: |  | ||||||
|         type: number |  | ||||||
|       vote_count: |  | ||||||
|         type: integer |  | ||||||
|     type: object |  | ||||||
|   tmdb.TVSearchResults: |  | ||||||
|     properties: |  | ||||||
|       page: |  | ||||||
|         type: integer |  | ||||||
|       results: |  | ||||||
|         items: |  | ||||||
|           $ref: '#/definitions/tmdb.TV' |  | ||||||
|         type: array |  | ||||||
|       total_pages: |  | ||||||
|         type: integer |  | ||||||
|       total_results: |  | ||||||
|         type: integer |  | ||||||
|     type: object |  | ||||||
| host: localhost:8080 |  | ||||||
| info: |  | ||||||
|   contact: {} |  | ||||||
|   description: API для работы с фильмами |  | ||||||
|   title: Neo Movies API |  | ||||||
|   version: "1.0" |  | ||||||
| paths: |  | ||||||
|   /bridge/tmdb/discover/movie: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get a list of movies based on filters |  | ||||||
|       parameters: |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/api.TMDBMoviesResponse' |  | ||||||
|       summary: Discover movies |  | ||||||
|       tags: |  | ||||||
|       - tmdb |  | ||||||
|   /bridge/tmdb/discover/tv: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get a list of TV shows based on filters |  | ||||||
|       parameters: |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/api.TMDBMoviesResponse' |  | ||||||
|       summary: Discover TV shows |  | ||||||
|       tags: |  | ||||||
|       - tmdb |  | ||||||
|   /bridge/tmdb/movie/{id}: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get detailed information about a specific movie directly from TMDB |  | ||||||
|       parameters: |  | ||||||
|       - description: Movie ID |  | ||||||
|         in: path |  | ||||||
|         name: id |  | ||||||
|         required: true |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/tmdb.Movie' |  | ||||||
|       summary: Get TMDB movie details |  | ||||||
|       tags: |  | ||||||
|       - tmdb |  | ||||||
|   /bridge/tmdb/movie/{id}/external_ids: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get external IDs (IMDb, Facebook, Instagram, Twitter) for a specific |  | ||||||
|         movie |  | ||||||
|       parameters: |  | ||||||
|       - description: Movie ID |  | ||||||
|         in: path |  | ||||||
|         name: id |  | ||||||
|         required: true |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/tmdb.ExternalIDs' |  | ||||||
|       summary: Get TMDB movie external IDs |  | ||||||
|       tags: |  | ||||||
|       - tmdb |  | ||||||
|   /bridge/tmdb/movie/popular: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get a list of popular movies directly from TMDB |  | ||||||
|       parameters: |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/api.TMDBMoviesResponse' |  | ||||||
|       summary: Get TMDB popular movies |  | ||||||
|       tags: |  | ||||||
|       - tmdb |  | ||||||
|   /bridge/tmdb/movie/top_rated: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get a list of top rated movies directly from TMDB |  | ||||||
|       parameters: |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/api.TMDBMoviesResponse' |  | ||||||
|       summary: Get TMDB top rated movies |  | ||||||
|       tags: |  | ||||||
|       - tmdb |  | ||||||
|   /bridge/tmdb/movie/upcoming: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get a list of upcoming movies directly from TMDB |  | ||||||
|       parameters: |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/api.TMDBMoviesResponse' |  | ||||||
|       summary: Get TMDB upcoming movies |  | ||||||
|       tags: |  | ||||||
|       - tmdb |  | ||||||
|   /bridge/tmdb/search/movie: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Search for movies directly in TMDB |  | ||||||
|       parameters: |  | ||||||
|       - description: Search query |  | ||||||
|         in: query |  | ||||||
|         name: query |  | ||||||
|         required: true |  | ||||||
|         type: string |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/tmdb.MoviesResponse' |  | ||||||
|       summary: Search TMDB movies |  | ||||||
|       tags: |  | ||||||
|       - tmdb |  | ||||||
|   /bridge/tmdb/search/tv: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Search for TV shows directly in TMDB |  | ||||||
|       parameters: |  | ||||||
|       - description: Search query |  | ||||||
|         in: query |  | ||||||
|         name: query |  | ||||||
|         required: true |  | ||||||
|         type: string |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/tmdb.TVSearchResults' |  | ||||||
|       summary: Search TMDB TV shows |  | ||||||
|       tags: |  | ||||||
|       - tmdb |  | ||||||
|   /bridge/tmdb/tv/{id}/external_ids: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get external IDs (IMDb, Facebook, Instagram, Twitter) for a specific |  | ||||||
|         TV show |  | ||||||
|       parameters: |  | ||||||
|       - description: TV Show ID |  | ||||||
|         in: path |  | ||||||
|         name: id |  | ||||||
|         required: true |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/tmdb.ExternalIDs' |  | ||||||
|       summary: Get TMDB TV show external IDs |  | ||||||
|       tags: |  | ||||||
|       - tmdb |  | ||||||
|   /movies/{id}: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get detailed information about a specific movie |  | ||||||
|       parameters: |  | ||||||
|       - description: Movie ID |  | ||||||
|         in: path |  | ||||||
|         name: id |  | ||||||
|         required: true |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/api.MovieDetails' |  | ||||||
|       summary: Get movie details |  | ||||||
|       tags: |  | ||||||
|       - movies |  | ||||||
|   /movies/popular: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get a list of popular movies |  | ||||||
|       parameters: |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/api.MoviesResponse' |  | ||||||
|       summary: Get popular movies |  | ||||||
|       tags: |  | ||||||
|       - movies |  | ||||||
|   /movies/search: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Search for movies |  | ||||||
|       parameters: |  | ||||||
|       - description: Search query |  | ||||||
|         in: query |  | ||||||
|         name: query |  | ||||||
|         required: true |  | ||||||
|         type: string |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/api.MoviesResponse' |  | ||||||
|       summary: Search movies |  | ||||||
|       tags: |  | ||||||
|       - movies |  | ||||||
|   /movies/top-rated: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get a list of top rated movies |  | ||||||
|       parameters: |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/api.MoviesResponse' |  | ||||||
|       summary: Get top rated movies |  | ||||||
|       tags: |  | ||||||
|       - movies |  | ||||||
|   /movies/upcoming: |  | ||||||
|     get: |  | ||||||
|       consumes: |  | ||||||
|       - application/json |  | ||||||
|       description: Get a list of upcoming movies |  | ||||||
|       parameters: |  | ||||||
|       - description: 'Page number (default: 1)' |  | ||||||
|         in: query |  | ||||||
|         name: page |  | ||||||
|         type: integer |  | ||||||
|       produces: |  | ||||||
|       - application/json |  | ||||||
|       responses: |  | ||||||
|         "200": |  | ||||||
|           description: OK |  | ||||||
|           schema: |  | ||||||
|             $ref: '#/definitions/api.MoviesResponse' |  | ||||||
|       summary: Get upcoming movies |  | ||||||
|       tags: |  | ||||||
|       - movies |  | ||||||
| swagger: "2.0" |  | ||||||
							
								
								
									
										67
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								go.mod
									
									
									
									
									
								
							| @@ -1,55 +1,32 @@ | |||||||
| module neomovies-api | module neomovies-api | ||||||
|  |  | ||||||
| go 1.21.0 | go 1.23.0 | ||||||
|  |  | ||||||
| toolchain go1.23.4 | toolchain go1.24.2 | ||||||
|  |  | ||||||
| require ( | require ( | ||||||
| 	github.com/gin-gonic/gin v1.10.0 | 	github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 | ||||||
|  | 	github.com/golang-jwt/jwt/v5 v5.2.0 | ||||||
|  | 	github.com/google/uuid v1.6.0 | ||||||
|  | 	github.com/gorilla/handlers v1.5.2 | ||||||
|  | 	github.com/gorilla/mux v1.8.1 | ||||||
| 	github.com/joho/godotenv v1.5.1 | 	github.com/joho/godotenv v1.5.1 | ||||||
| 	github.com/swaggo/files v1.0.1 | 	go.mongodb.org/mongo-driver v1.11.6 | ||||||
| 	github.com/swaggo/gin-swagger v1.6.0 | 	golang.org/x/crypto v0.17.0 | ||||||
| 	github.com/swaggo/swag v1.16.2 | 	golang.org/x/oauth2 v0.30.0 | ||||||
| ) | ) | ||||||
|  |  | ||||||
| require ( | require ( | ||||||
| 	github.com/KyleBanks/depth v1.2.1 // indirect | 	cloud.google.com/go/compute/metadata v0.3.0 // indirect | ||||||
| 	github.com/PuerkitoBio/purell v1.1.1 // indirect | 	github.com/felixge/httpsnoop v1.0.3 // indirect | ||||||
| 	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect | 	github.com/golang/snappy v0.0.1 // indirect | ||||||
| 	github.com/bytedance/sonic v1.12.6 // indirect | 	github.com/klauspost/compress v1.13.6 // indirect | ||||||
| 	github.com/bytedance/sonic/loader v0.2.1 // indirect | 	github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect | ||||||
| 	github.com/cloudwego/base64x v0.1.4 // indirect | 	github.com/pkg/errors v0.9.1 // indirect | ||||||
| 	github.com/cloudwego/iasm v0.2.0 // indirect | 	github.com/xdg-go/pbkdf2 v1.0.0 // indirect | ||||||
| 	github.com/gabriel-vasile/mimetype v1.4.7 // indirect | 	github.com/xdg-go/scram v1.1.1 // indirect | ||||||
| 	github.com/gin-contrib/cors v1.7.3 // indirect | 	github.com/xdg-go/stringprep v1.0.3 // indirect | ||||||
| 	github.com/gin-contrib/sse v0.1.0 // indirect | 	github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect | ||||||
| 	github.com/go-openapi/jsonpointer v0.19.5 // indirect | 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect | ||||||
| 	github.com/go-openapi/jsonreference v0.19.6 // indirect | 	golang.org/x/text v0.14.0 // indirect | ||||||
| 	github.com/go-openapi/spec v0.20.4 // indirect |  | ||||||
| 	github.com/go-openapi/swag v0.19.15 // indirect |  | ||||||
| 	github.com/go-playground/locales v0.14.1 // indirect |  | ||||||
| 	github.com/go-playground/universal-translator v0.18.1 // indirect |  | ||||||
| 	github.com/go-playground/validator/v10 v10.23.0 // indirect |  | ||||||
| 	github.com/goccy/go-json v0.10.4 // indirect |  | ||||||
| 	github.com/josharian/intern v1.0.0 // indirect |  | ||||||
| 	github.com/json-iterator/go v1.1.12 // indirect |  | ||||||
| 	github.com/klauspost/cpuid/v2 v2.2.9 // indirect |  | ||||||
| 	github.com/leodido/go-urn v1.4.0 // indirect |  | ||||||
| 	github.com/mailru/easyjson v0.7.6 // indirect |  | ||||||
| 	github.com/mattn/go-isatty v0.0.20 // indirect |  | ||||||
| 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect |  | ||||||
| 	github.com/modern-go/reflect2 v1.0.2 // indirect |  | ||||||
| 	github.com/pelletier/go-toml/v2 v2.2.3 // indirect |  | ||||||
| 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect |  | ||||||
| 	github.com/ugorji/go/codec v1.2.12 // indirect |  | ||||||
| 	golang.org/x/arch v0.12.0 // indirect |  | ||||||
| 	golang.org/x/crypto v0.31.0 // indirect |  | ||||||
| 	golang.org/x/net v0.33.0 // indirect |  | ||||||
| 	golang.org/x/sys v0.28.0 // indirect |  | ||||||
| 	golang.org/x/text v0.21.0 // indirect |  | ||||||
| 	golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect |  | ||||||
| 	google.golang.org/protobuf v1.36.1 // indirect |  | ||||||
| 	gopkg.in/yaml.v2 v2.4.0 // indirect |  | ||||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect |  | ||||||
| 	h12.io/socks v1.0.3 // indirect |  | ||||||
| ) | ) | ||||||
|   | |||||||
							
								
								
									
										189
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										189
									
								
								go.sum
									
									
									
									
									
								
							| @@ -1,156 +1,75 @@ | |||||||
| github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= | cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= | ||||||
| github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= | cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= | ||||||
| github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= | github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM= | ||||||
| github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= | github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06/go.mod h1:/wotfjM8I3m8NuIHPz3S8k+CCYH80EqDT8ZeNLqMQm0= | ||||||
| github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= |  | ||||||
| github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= |  | ||||||
| github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk= |  | ||||||
| github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= |  | ||||||
| github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= |  | ||||||
| github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= |  | ||||||
| github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= |  | ||||||
| github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= |  | ||||||
| github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= |  | ||||||
| github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= |  | ||||||
| github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= |  | ||||||
| github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= |  | ||||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||||
|  | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||||
| github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= | github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= | ||||||
| github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= | github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= | ||||||
| github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns= | github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= | ||||||
| github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4= | github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= | ||||||
| github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= | github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= | ||||||
| github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= | ||||||
| github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= | ||||||
| github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= | ||||||
| github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||||||
| github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||||
| github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= | github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= | ||||||
| github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= | github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= | ||||||
| github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= | ||||||
| github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= | ||||||
| github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= |  | ||||||
| github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= |  | ||||||
| github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= |  | ||||||
| github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= |  | ||||||
| github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= |  | ||||||
| github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= |  | ||||||
| github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= |  | ||||||
| github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= |  | ||||||
| github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= |  | ||||||
| github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= |  | ||||||
| github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= |  | ||||||
| github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= |  | ||||||
| github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= |  | ||||||
| github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= |  | ||||||
| github.com/h12w/go-socks5 v0.0.0-20200522160539-76189e178364/go.mod h1:eDJQioIyy4Yn3MVivT7rv/39gAJTrA7lgmYr8EW950c= |  | ||||||
| github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= | ||||||
| github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= | ||||||
| github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= | github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= | ||||||
| github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= | github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= | ||||||
| github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= | ||||||
| github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= |  | ||||||
| github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= |  | ||||||
| github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= |  | ||||||
| github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= |  | ||||||
| github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= |  | ||||||
| github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= | ||||||
| github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | ||||||
|  | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= | ||||||
| github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | ||||||
| github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= | ||||||
| github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= | ||||||
| github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | ||||||
| github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | ||||||
| github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||||
| github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= |  | ||||||
| github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= |  | ||||||
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |  | ||||||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |  | ||||||
| github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |  | ||||||
| github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= |  | ||||||
| github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= |  | ||||||
| github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= |  | ||||||
| github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= |  | ||||||
| github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= |  | ||||||
| github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= |  | ||||||
| github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= |  | ||||||
| github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= |  | ||||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||||
| github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= | ||||||
| github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= |  | ||||||
| github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= |  | ||||||
| github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||||
| github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= | ||||||
| github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= | ||||||
| github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= | ||||||
| github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= | ||||||
| github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= | github.com/xdg-go/scram v1.1.1 h1:VOMT+81stJgXW3CpHyqHN3AXDYIMsx56mEFrB37Mb/E= | ||||||
| github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= | github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= | ||||||
| github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= | github.com/xdg-go/stringprep v1.0.3 h1:kdwGpVNwPFtjs98xCGkHjQtGKh86rDcRZN17QEMCOIs= | ||||||
| github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= | github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= | ||||||
| github.com/swaggo/swag v1.16.2 h1:28Pp+8DkQoV+HLzLx8RGJZXNGKbFqnuvSbAAtoxiY04= | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= | ||||||
| github.com/swaggo/swag v1.16.2/go.mod h1:6YzXnDcpr0767iOejs318CwYkCQqyGer6BizOg03f+E= | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= | ||||||
| github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= | go.mongodb.org/mongo-driver v1.11.6 h1:XM7G6PjiGAO5betLF13BIa5TlLUUE3uJ/2Ox3Lz1K+o= | ||||||
| github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | go.mongodb.org/mongo-driver v1.11.6/go.mod h1:G9TgswdsWjX4tmDA5zfs2+6AEPpYJwqblyjsfuh8oXY= | ||||||
| github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= | ||||||
| github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= | ||||||
| github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= | ||||||
| golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= | ||||||
| golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= | ||||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= | ||||||
| golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= | ||||||
| golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= |  | ||||||
| golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= |  | ||||||
| golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |  | ||||||
| golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= |  | ||||||
| golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= |  | ||||||
| golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= |  | ||||||
| golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= |  | ||||||
| golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= |  | ||||||
| golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= |  | ||||||
| golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |  | ||||||
| golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |  | ||||||
| golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |  | ||||||
| golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
| golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
| golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |  | ||||||
| golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |  | ||||||
| golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |  | ||||||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |  | ||||||
| golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= |  | ||||||
| golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |  | ||||||
| golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||||
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= |  | ||||||
| golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= |  | ||||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= |  | ||||||
| golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |  | ||||||
| golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||||
| golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= | ||||||
| golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= | ||||||
| golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= | ||||||
| golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= |  | ||||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||||
| golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= | ||||||
| golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||||
| golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= |  | ||||||
| golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= |  | ||||||
| golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |  | ||||||
| google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= |  | ||||||
| google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= |  | ||||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||||
| gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||||
| gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |  | ||||||
| gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= |  | ||||||
| gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= |  | ||||||
| gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= |  | ||||||
| gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||||
| gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |  | ||||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||||
| h12.io/socks v1.0.3 h1:Ka3qaQewws4j4/eDQnOdpr4wXsC//dXtWvftlIcCQUo= |  | ||||||
| h12.io/socks v1.0.3/go.mod h1:AIhxy1jOId/XCz9BO+EIgNL2rQiPTBNnOfnVnQ+3Eck= |  | ||||||
| nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= |  | ||||||
|   | |||||||
| @@ -1,505 +0,0 @@ | |||||||
| package api |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"net/http" |  | ||||||
|  |  | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	"neomovies-api/internal/tmdb" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| // GetPopularMovies возвращает список популярных фильмов |  | ||||||
| // @Summary     Get popular movies |  | ||||||
| // @Description Get a list of popular movies |  | ||||||
| // @Tags        movies |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       page query    int false "Page number (default: 1)" |  | ||||||
| // @Success     200  {object} MoviesResponse |  | ||||||
| // @Router      /movies/popular [get] |  | ||||||
| func GetPopularMovies(c *gin.Context) { |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
|  |  | ||||||
| 	movies, err := tmdbClient.GetPopular(page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Добавляем полные URL для изображений |  | ||||||
| 	for i := range movies.Results { |  | ||||||
| 		if movies.Results[i].PosterPath != "" { |  | ||||||
| 			movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500") |  | ||||||
| 		} |  | ||||||
| 		if movies.Results[i].BackdropPath != "" { |  | ||||||
| 			movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280") |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, movies) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetMovie возвращает информацию о фильме |  | ||||||
| // @Summary     Get movie details |  | ||||||
| // @Description Get detailed information about a specific movie |  | ||||||
| // @Tags        movies |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       id  path     int true "Movie ID" |  | ||||||
| // @Success     200 {object} MovieDetails |  | ||||||
| // @Router      /movies/{id} [get] |  | ||||||
| func GetMovie(c *gin.Context) { |  | ||||||
| 	id := c.Param("id") |  | ||||||
|  |  | ||||||
| 	movie, err := tmdbClient.GetMovie(id) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Добавляем полные URL для изображений |  | ||||||
| 	if movie.PosterPath != "" { |  | ||||||
| 		movie.PosterPath = tmdbClient.GetImageURL(movie.PosterPath, "original") |  | ||||||
| 	} |  | ||||||
| 	if movie.BackdropPath != "" { |  | ||||||
| 		movie.BackdropPath = tmdbClient.GetImageURL(movie.BackdropPath, "original") |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Обрабатываем изображения для коллекции |  | ||||||
| 	if movie.BelongsToCollection != nil { |  | ||||||
| 		if movie.BelongsToCollection.PosterPath != "" { |  | ||||||
| 			movie.BelongsToCollection.PosterPath = tmdbClient.GetImageURL(movie.BelongsToCollection.PosterPath, "w500") |  | ||||||
| 		} |  | ||||||
| 		if movie.BelongsToCollection.BackdropPath != "" { |  | ||||||
| 			movie.BelongsToCollection.BackdropPath = tmdbClient.GetImageURL(movie.BelongsToCollection.BackdropPath, "w1280") |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Обрабатываем логотипы компаний |  | ||||||
| 	for i := range movie.ProductionCompanies { |  | ||||||
| 		if movie.ProductionCompanies[i].LogoPath != "" { |  | ||||||
| 			movie.ProductionCompanies[i].LogoPath = tmdbClient.GetImageURL(movie.ProductionCompanies[i].LogoPath, "w185") |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, movie) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // SearchMovies ищет фильмы |  | ||||||
| // @Summary     Поиск фильмов |  | ||||||
| // @Description Поиск фильмов по запросу |  | ||||||
| // @Tags        movies |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       query query    string true  "Поисковый запрос" |  | ||||||
| // @Param       page  query    string false "Номер страницы (по умолчанию 1)" |  | ||||||
| // @Success     200   {object} SearchResponse |  | ||||||
| // @Router      /movies/search [get] |  | ||||||
| func SearchMovies(c *gin.Context) { |  | ||||||
| 	query := c.Query("query") |  | ||||||
| 	if query == "" { |  | ||||||
| 		c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter is required"}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
|  |  | ||||||
| 	// Получаем результаты поиска |  | ||||||
| 	results, err := tmdbClient.SearchMovies(query, page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Преобразуем результаты в формат ответа |  | ||||||
| 	response := SearchResponse{ |  | ||||||
| 		Page:         results.Page, |  | ||||||
| 		TotalPages:   results.TotalPages, |  | ||||||
| 		TotalResults: results.TotalResults, |  | ||||||
| 		Results:      make([]MovieResponse, 0), |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Преобразуем каждый фильм |  | ||||||
| 	for _, movie := range results.Results { |  | ||||||
| 		// Форматируем дату |  | ||||||
| 		releaseDate := formatDate(movie.ReleaseDate) |  | ||||||
|  |  | ||||||
| 		// Добавляем фильм в результаты |  | ||||||
| 		response.Results = append(response.Results, MovieResponse{ |  | ||||||
| 			ID:           movie.ID, |  | ||||||
| 			Title:        movie.Title, |  | ||||||
| 			Overview:     movie.Overview, |  | ||||||
| 			ReleaseDate:  releaseDate, |  | ||||||
| 			VoteAverage:  movie.VoteAverage, |  | ||||||
| 			PosterPath:   tmdbClient.GetImageURL(movie.PosterPath, "w500"), |  | ||||||
| 			BackdropPath: tmdbClient.GetImageURL(movie.BackdropPath, "w1280"), |  | ||||||
| 		}) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, response) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetTopRatedMovies возвращает список лучших фильмов |  | ||||||
| // @Summary     Get top rated movies |  | ||||||
| // @Description Get a list of top rated movies |  | ||||||
| // @Tags        movies |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       page query    int false "Page number (default: 1)" |  | ||||||
| // @Success     200  {object} MoviesResponse |  | ||||||
| // @Router      /movies/top-rated [get] |  | ||||||
| func GetTopRatedMovies(c *gin.Context) { |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
|  |  | ||||||
| 	movies, err := tmdbClient.GetTopRated(page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Добавляем полные URL для изображений |  | ||||||
| 	for i := range movies.Results { |  | ||||||
| 		if movies.Results[i].PosterPath != "" { |  | ||||||
| 			movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500") |  | ||||||
| 		} |  | ||||||
| 		if movies.Results[i].BackdropPath != "" { |  | ||||||
| 			movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280") |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, movies) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetUpcomingMovies возвращает список предстоящих фильмов |  | ||||||
| // @Summary     Get upcoming movies |  | ||||||
| // @Description Get a list of upcoming movies |  | ||||||
| // @Tags        movies |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       page query    int false "Page number (default: 1)" |  | ||||||
| // @Success     200  {object} MoviesResponse |  | ||||||
| // @Router      /movies/upcoming [get] |  | ||||||
| func GetUpcomingMovies(c *gin.Context) { |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
|  |  | ||||||
| 	movies, err := tmdbClient.GetUpcoming(page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Добавляем полные URL для изображений |  | ||||||
| 	for i := range movies.Results { |  | ||||||
| 		if movies.Results[i].PosterPath != "" { |  | ||||||
| 			movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500") |  | ||||||
| 		} |  | ||||||
| 		if movies.Results[i].BackdropPath != "" { |  | ||||||
| 			movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280") |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, movies) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetTMDBPopularMovies возвращает список популярных фильмов из TMDB |  | ||||||
| // @Summary     Get TMDB popular movies |  | ||||||
| // @Description Get a list of popular movies directly from TMDB |  | ||||||
| // @Tags        tmdb |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       page query    int false "Page number (default: 1)" |  | ||||||
| // @Success     200  {object} TMDBMoviesResponse |  | ||||||
| // @Router      /bridge/tmdb/movie/popular [get] |  | ||||||
| func GetTMDBPopularMovies(c *gin.Context) { |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
|  |  | ||||||
| 	movies, err := tmdbClient.GetPopular(page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Добавляем полные URL для изображений |  | ||||||
| 	for i := range movies.Results { |  | ||||||
| 		if movies.Results[i].PosterPath != "" { |  | ||||||
| 			movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500") |  | ||||||
| 		} |  | ||||||
| 		if movies.Results[i].BackdropPath != "" { |  | ||||||
| 			movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280") |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, movies) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetTMDBMovie возвращает информацию о фильме из TMDB |  | ||||||
| // @Summary     Get TMDB movie details |  | ||||||
| // @Description Get detailed information about a specific movie directly from TMDB |  | ||||||
| // @Tags        tmdb |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       id  path     int true "Movie ID" |  | ||||||
| // @Success     200 {object} tmdb.Movie |  | ||||||
| // @Router      /bridge/tmdb/movie/{id} [get] |  | ||||||
| func GetTMDBMovie(c *gin.Context) { |  | ||||||
| 	id := c.Param("id") |  | ||||||
|  |  | ||||||
| 	movie, err := tmdbClient.GetMovie(id) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, movie) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetTMDBTopRatedMovies возвращает список лучших фильмов из TMDB |  | ||||||
| // @Summary     Get TMDB top rated movies |  | ||||||
| // @Description Get a list of top rated movies directly from TMDB |  | ||||||
| // @Tags        tmdb |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       page query    int false "Page number (default: 1)" |  | ||||||
| // @Success     200  {object} TMDBMoviesResponse |  | ||||||
| // @Router      /bridge/tmdb/movie/top_rated [get] |  | ||||||
| func GetTMDBTopRatedMovies(c *gin.Context) { |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
|  |  | ||||||
| 	movies, err := tmdbClient.GetTopRated(page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Добавляем полные URL для изображений |  | ||||||
| 	for i := range movies.Results { |  | ||||||
| 		if movies.Results[i].PosterPath != "" { |  | ||||||
| 			movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500") |  | ||||||
| 		} |  | ||||||
| 		if movies.Results[i].BackdropPath != "" { |  | ||||||
| 			movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280") |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, movies) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetTMDBUpcomingMovies возвращает список предстоящих фильмов из TMDB |  | ||||||
| // @Summary     Get TMDB upcoming movies |  | ||||||
| // @Description Get a list of upcoming movies directly from TMDB |  | ||||||
| // @Tags        tmdb |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       page query    int false "Page number (default: 1)" |  | ||||||
| // @Success     200  {object} TMDBMoviesResponse |  | ||||||
| // @Router      /bridge/tmdb/movie/upcoming [get] |  | ||||||
| func GetTMDBUpcomingMovies(c *gin.Context) { |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
|  |  | ||||||
| 	movies, err := tmdbClient.GetUpcoming(page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Добавляем полные URL для изображений |  | ||||||
| 	for i := range movies.Results { |  | ||||||
| 		if movies.Results[i].PosterPath != "" { |  | ||||||
| 			movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500") |  | ||||||
| 		} |  | ||||||
| 		if movies.Results[i].BackdropPath != "" { |  | ||||||
| 			movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280") |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, movies) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // SearchTMDBMovies ищет фильмы в TMDB |  | ||||||
| // @Summary     Search TMDB movies |  | ||||||
| // @Description Search for movies directly in TMDB |  | ||||||
| // @Tags        tmdb |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       query query    string true  "Search query" |  | ||||||
| // @Param       page  query    int    false "Page number (default: 1)" |  | ||||||
| // @Success     200   {object} tmdb.MoviesResponse |  | ||||||
| // @Router      /bridge/tmdb/search/movie [get] |  | ||||||
| func SearchTMDBMovies(c *gin.Context) { |  | ||||||
| 	query := c.Query("query") |  | ||||||
| 	if query == "" { |  | ||||||
| 		c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter is required"}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
| 	movies, err := tmdbClient.SearchMovies(query, page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, movies) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // SearchTMDBTV ищет сериалы в TMDB |  | ||||||
| // @Summary     Search TMDB TV shows |  | ||||||
| // @Description Search for TV shows directly in TMDB |  | ||||||
| // @Tags        tmdb |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       query query    string true  "Search query" |  | ||||||
| // @Param       page  query    int    false "Page number (default: 1)" |  | ||||||
| // @Success     200   {object} tmdb.TVSearchResults |  | ||||||
| // @Router      /bridge/tmdb/search/tv [get] |  | ||||||
| func SearchTMDBTV(c *gin.Context) { |  | ||||||
| 	query := c.Query("query") |  | ||||||
| 	if query == "" { |  | ||||||
| 		c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter is required"}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
| 	tv, err := tmdbClient.SearchTV(query, page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, tv) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // DiscoverMovies возвращает список фильмов по фильтрам |  | ||||||
| // @Summary     Discover movies |  | ||||||
| // @Description Get a list of movies based on filters |  | ||||||
| // @Tags        tmdb |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       page query    int false "Page number (default: 1)" |  | ||||||
| // @Success     200  {object} TMDBMoviesResponse |  | ||||||
| // @Router      /bridge/tmdb/discover/movie [get] |  | ||||||
| func DiscoverMovies(c *gin.Context) { |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
| 	movies, err := tmdbClient.DiscoverMovies(page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 	c.JSON(http.StatusOK, movies) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // DiscoverTV возвращает список сериалов по фильтрам |  | ||||||
| // @Summary     Discover TV shows |  | ||||||
| // @Description Get a list of TV shows based on filters |  | ||||||
| // @Tags        tmdb |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       page query    int false "Page number (default: 1)" |  | ||||||
| // @Success     200  {object} TMDBMoviesResponse |  | ||||||
| // @Router      /bridge/tmdb/discover/tv [get] |  | ||||||
| func DiscoverTV(c *gin.Context) { |  | ||||||
| 	page := c.DefaultQuery("page", "1") |  | ||||||
| 	shows, err := tmdbClient.DiscoverTV(page) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 	c.JSON(http.StatusOK, shows) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetTMDBMovieExternalIDs возвращает внешние идентификаторы фильма |  | ||||||
| // @Summary     Get TMDB movie external IDs |  | ||||||
| // @Description Get external IDs (IMDb, Facebook, Instagram, Twitter) for a specific movie |  | ||||||
| // @Tags        tmdb |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       id  path     int true "Movie ID" |  | ||||||
| // @Success     200 {object} tmdb.ExternalIDs |  | ||||||
| // @Router      /bridge/tmdb/movie/{id}/external_ids [get] |  | ||||||
| func GetTMDBMovieExternalIDs(c *gin.Context) { |  | ||||||
| 	id := c.Param("id") |  | ||||||
|  |  | ||||||
| 	externalIDs, err := tmdbClient.GetMovieExternalIDs(id) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, externalIDs) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetTMDBTVExternalIDs возвращает внешние идентификаторы сериала |  | ||||||
| // @Summary     Get TMDB TV show external IDs |  | ||||||
| // @Description Get external IDs (IMDb, Facebook, Instagram, Twitter) for a specific TV show |  | ||||||
| // @Tags        tmdb |  | ||||||
| // @Accept      json |  | ||||||
| // @Produce     json |  | ||||||
| // @Param       id  path     int true "TV Show ID" |  | ||||||
| // @Success     200 {object} tmdb.ExternalIDs |  | ||||||
| // @Router      /bridge/tmdb/tv/{id}/external_ids [get] |  | ||||||
| func GetTMDBTVExternalIDs(c *gin.Context) { |  | ||||||
| 	id := c.Param("id") |  | ||||||
|  |  | ||||||
| 	externalIDs, err := tmdbClient.GetTVExternalIDs(id) |  | ||||||
| 	if err != nil { |  | ||||||
| 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	c.JSON(http.StatusOK, externalIDs) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // HealthCheck godoc |  | ||||||
| // @Summary Проверка работоспособности API |  | ||||||
| // @Description Проверяет, что API работает |  | ||||||
| // @Tags health |  | ||||||
| // @Produce json |  | ||||||
| // @Success 200 {object} map[string]string |  | ||||||
| // @Router /health [get] |  | ||||||
| func HealthCheck(c *gin.Context) { |  | ||||||
| 	c.JSON(http.StatusOK, gin.H{ |  | ||||||
| 		"status": "ok", |  | ||||||
| 	}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // InitTMDBClientWithProxy инициализирует TMDB клиент с прокси |  | ||||||
| func InitTMDBClientWithProxy(apiKey string, proxyAddr string) error { |  | ||||||
| 	tmdbClient = tmdb.NewClient(apiKey) |  | ||||||
| 	return tmdbClient.SetSOCKS5Proxy(proxyAddr) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Admin handlers |  | ||||||
|  |  | ||||||
| // GetAdminMovies возвращает список фильмов для админа |  | ||||||
| func GetAdminMovies(c *gin.Context) { |  | ||||||
| 	c.JSON(http.StatusOK, gin.H{"message": "Admin movies list"}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // ToggleMovieVisibility переключает видимость фильма |  | ||||||
| func ToggleMovieVisibility(c *gin.Context) { |  | ||||||
| 	c.JSON(http.StatusOK, gin.H{"message": "Movie visibility toggled"}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetUsers возвращает список пользователей |  | ||||||
| func GetUsers(c *gin.Context) { |  | ||||||
| 	c.JSON(http.StatusOK, gin.H{"message": "Users list"}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // CreateUser создает нового пользователя |  | ||||||
| func CreateUser(c *gin.Context) { |  | ||||||
| 	c.JSON(http.StatusOK, gin.H{"message": "User created"}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // ToggleAdmin переключает права администратора |  | ||||||
| func ToggleAdmin(c *gin.Context) { |  | ||||||
| 	c.JSON(http.StatusOK, gin.H{"message": "Admin status toggled"}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // SendVerification отправляет код верификации |  | ||||||
| func SendVerification(c *gin.Context) { |  | ||||||
| 	c.JSON(http.StatusOK, gin.H{"message": "Verification code sent"}) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // VerifyCode проверяет код верификации |  | ||||||
| func VerifyCode(c *gin.Context) { |  | ||||||
| 	c.JSON(http.StatusOK, gin.H{"message": "Code verified"}) |  | ||||||
| } |  | ||||||
| @@ -1,14 +0,0 @@ | |||||||
| package api |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"neomovies-api/internal/tmdb" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| var ( |  | ||||||
| 	tmdbClient *tmdb.Client |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| // InitTMDBClient инициализирует TMDB клиент |  | ||||||
| func InitTMDBClient(apiKey string) { |  | ||||||
| 	tmdbClient = tmdb.NewClient(apiKey) |  | ||||||
| } |  | ||||||
| @@ -1,64 +0,0 @@ | |||||||
| package api |  | ||||||
|  |  | ||||||
| // Genre представляет жанр фильма |  | ||||||
| type Genre struct { |  | ||||||
| 	ID   int    `json:"id"` |  | ||||||
| 	Name string `json:"name"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Movie представляет базовую информацию о фильме |  | ||||||
| type Movie struct { |  | ||||||
| 	ID           int     `json:"id"` |  | ||||||
| 	Title        string  `json:"title"` |  | ||||||
| 	Overview     string  `json:"overview"` |  | ||||||
| 	PosterPath   *string `json:"poster_path"` |  | ||||||
| 	BackdropPath *string `json:"backdrop_path"` |  | ||||||
| 	ReleaseDate  string  `json:"release_date"` |  | ||||||
| 	VoteAverage  float64 `json:"vote_average"` |  | ||||||
| 	Genres       []Genre `json:"genres"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // MovieDetails представляет детальную информацию о фильме |  | ||||||
| type MovieDetails struct { |  | ||||||
| 	Movie |  | ||||||
| 	Runtime int    `json:"runtime"` |  | ||||||
| 	Tagline string `json:"tagline"` |  | ||||||
| 	Budget  int    `json:"budget"` |  | ||||||
| 	Revenue int    `json:"revenue"` |  | ||||||
| 	Status  string `json:"status"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // MoviesResponse представляет ответ со списком фильмов |  | ||||||
| type MoviesResponse struct { |  | ||||||
| 	Page         int     `json:"page"` |  | ||||||
| 	TotalPages   int     `json:"total_pages"` |  | ||||||
| 	TotalResults int     `json:"total_results"` |  | ||||||
| 	Results      []Movie `json:"results"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // TMDBMoviesResponse представляет ответ со списком фильмов от TMDB API |  | ||||||
| type TMDBMoviesResponse struct { |  | ||||||
| 	Page         int     `json:"page"` |  | ||||||
| 	TotalPages   int     `json:"total_pages"` |  | ||||||
| 	TotalResults int     `json:"total_results"` |  | ||||||
| 	Results      []Movie `json:"results"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // SearchResponse представляет ответ на поисковый запрос |  | ||||||
| type SearchResponse struct { |  | ||||||
| 	Page         int             `json:"page"` |  | ||||||
| 	TotalPages   int             `json:"total_pages"` |  | ||||||
| 	TotalResults int             `json:"total_results"` |  | ||||||
| 	Results      []MovieResponse `json:"results"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // MovieResponse представляет информацию о фильме в ответе API |  | ||||||
| type MovieResponse struct { |  | ||||||
| 	ID           int     `json:"id"` |  | ||||||
| 	Title        string  `json:"title"` |  | ||||||
| 	Overview     string  `json:"overview"` |  | ||||||
| 	ReleaseDate  string  `json:"release_date"` |  | ||||||
| 	VoteAverage  float64 `json:"vote_average"` |  | ||||||
| 	PosterPath   string  `json:"poster_path"` |  | ||||||
| 	BackdropPath string  `json:"backdrop_path"` |  | ||||||
| } |  | ||||||
| @@ -1,24 +0,0 @@ | |||||||
| package api |  | ||||||
|  |  | ||||||
| import "time" |  | ||||||
|  |  | ||||||
| // formatDate форматирует дату в более читаемый формат |  | ||||||
| func formatDate(date string) string { |  | ||||||
| 	if date == "" { |  | ||||||
| 		return "" |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Парсим дату из формата YYYY-MM-DD |  | ||||||
| 	t, err := time.Parse("2006-01-02", date) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return date |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Форматируем дату в русском стиле |  | ||||||
| 	months := []string{ |  | ||||||
| 		"января", "февраля", "марта", "апреля", "мая", "июня", |  | ||||||
| 		"июля", "августа", "сентября", "октября", "ноября", "декабря", |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return t.Format("2") + " " + months[t.Month()-1] + " " + t.Format("2006") |  | ||||||
| } |  | ||||||
| @@ -1,399 +0,0 @@ | |||||||
| package tmdb |  | ||||||
|  |  | ||||||
| import ( |  | ||||||
| 	"context" |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"fmt" |  | ||||||
| 	"io" |  | ||||||
| 	"log" |  | ||||||
| 	"net" |  | ||||||
| 	"net/http" |  | ||||||
| 	"net/url" |  | ||||||
| 	"path" |  | ||||||
| 	"time" |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| const ( |  | ||||||
| 	baseURL     = "https://api.themoviedb.org/3" |  | ||||||
| 	imageBaseURL = "https://image.tmdb.org/t/p" |  | ||||||
| 	googleDNS   = "8.8.8.8:53"    // Google Public DNS |  | ||||||
| 	cloudflareDNS = "1.1.1.1:53"  // Cloudflare DNS |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| // Client представляет клиент для работы с TMDB API |  | ||||||
| type Client struct { |  | ||||||
| 	apiKey     string |  | ||||||
| 	httpClient *http.Client |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // NewClient создает новый клиент TMDB API с кастомным DNS |  | ||||||
| func NewClient(apiKey string) *Client { |  | ||||||
| 	// Создаем кастомный DNS резолвер с двумя DNS серверами |  | ||||||
| 	dialer := &net.Dialer{ |  | ||||||
| 		Timeout: 5 * time.Second, |  | ||||||
| 		Resolver: &net.Resolver{ |  | ||||||
| 			PreferGo: true, |  | ||||||
| 			Dial: func(ctx context.Context, network, address string) (net.Conn, error) { |  | ||||||
| 				// Пробуем сначала Google DNS |  | ||||||
| 				d := net.Dialer{Timeout: 5 * time.Second} |  | ||||||
| 				conn, err := d.DialContext(ctx, "udp", googleDNS) |  | ||||||
| 				if err != nil { |  | ||||||
| 					log.Printf("Failed to connect to Google DNS, trying Cloudflare: %v", err) |  | ||||||
| 					// Если Google DNS не отвечает, пробуем Cloudflare |  | ||||||
| 					return d.DialContext(ctx, "udp", cloudflareDNS) |  | ||||||
| 				} |  | ||||||
| 				return conn, nil |  | ||||||
| 			}, |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Создаем транспорт с кастомным диалером |  | ||||||
| 	transport := &http.Transport{ |  | ||||||
| 		DialContext:           dialer.DialContext, |  | ||||||
| 		TLSHandshakeTimeout:  5 * time.Second, |  | ||||||
| 		ResponseHeaderTimeout: 10 * time.Second, |  | ||||||
| 		ExpectContinueTimeout: 1 * time.Second, |  | ||||||
| 		MaxIdleConns:          100, |  | ||||||
| 		IdleConnTimeout:       90 * time.Second, |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	client := &Client{ |  | ||||||
| 		apiKey: apiKey, |  | ||||||
| 		httpClient: &http.Client{ |  | ||||||
| 			Transport: transport, |  | ||||||
| 			Timeout:   10 * time.Second, |  | ||||||
| 		}, |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Проверяем работу DNS и API |  | ||||||
| 	log.Println("Testing DNS resolution and TMDB API access...") |  | ||||||
| 	 |  | ||||||
| 	// Тест 1: Проверяем резолвинг через DNS |  | ||||||
| 	ips, err := net.LookupIP("api.themoviedb.org") |  | ||||||
| 	if err != nil { |  | ||||||
| 		log.Printf("Warning: DNS lookup failed: %v", err) |  | ||||||
| 	} else { |  | ||||||
| 		log.Printf("Successfully resolved api.themoviedb.org to: %v", ips) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Тест 2: Проверяем наш IP |  | ||||||
| 	resp, err := client.httpClient.Get("https://ipinfo.io/json") |  | ||||||
| 	if err != nil { |  | ||||||
| 		log.Printf("Warning: Failed to check our IP: %v", err) |  | ||||||
| 	} else { |  | ||||||
| 		defer resp.Body.Close() |  | ||||||
| 		var ipInfo struct { |  | ||||||
| 			IP       string `json:"ip"` |  | ||||||
| 			City     string `json:"city"` |  | ||||||
| 			Country  string `json:"country"` |  | ||||||
| 			Org      string `json:"org"` |  | ||||||
| 		} |  | ||||||
| 		if err := json.NewDecoder(resp.Body).Decode(&ipInfo); err != nil { |  | ||||||
| 			log.Printf("Warning: Failed to decode IP info: %v", err) |  | ||||||
| 		} else { |  | ||||||
| 			log.Printf("Our IP info: IP=%s, City=%s, Country=%s, Org=%s", |  | ||||||
| 				ipInfo.IP, ipInfo.City, ipInfo.Country, ipInfo.Org) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Тест 3: Проверяем доступ к TMDB API |  | ||||||
| 	testURL := fmt.Sprintf("%s/movie/popular?api_key=%s", baseURL, apiKey) |  | ||||||
| 	resp, err = client.httpClient.Get(testURL) |  | ||||||
| 	if err != nil { |  | ||||||
| 		log.Printf("Warning: TMDB API test failed: %v", err) |  | ||||||
| 	} else { |  | ||||||
| 		defer resp.Body.Close() |  | ||||||
| 		if resp.StatusCode == http.StatusOK { |  | ||||||
| 			log.Println("Successfully connected to TMDB API!") |  | ||||||
| 		} else { |  | ||||||
| 			log.Printf("Warning: TMDB API returned status code: %d", resp.StatusCode) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return client |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // SetSOCKS5Proxy устанавливает SOCKS5 прокси для клиента |  | ||||||
| func (c *Client) SetSOCKS5Proxy(proxyAddr string) error { |  | ||||||
| 	return fmt.Errorf("proxy support has been removed in favor of custom DNS resolvers") |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // makeRequest выполняет HTTP запрос к TMDB API |  | ||||||
| func (c *Client) makeRequest(method, endpoint string, params url.Values) ([]byte, error) { |  | ||||||
| 	// Создаем URL |  | ||||||
| 	u, err := url.Parse(baseURL) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, fmt.Errorf("failed to parse base URL: %v", err) |  | ||||||
| 	} |  | ||||||
| 	u.Path = path.Join(u.Path, endpoint) |  | ||||||
| 	if params == nil { |  | ||||||
| 		params = url.Values{} |  | ||||||
| 	} |  | ||||||
| 	u.RawQuery = params.Encode() |  | ||||||
|  |  | ||||||
| 	// Создаем запрос |  | ||||||
| 	req, err := http.NewRequest(method, u.String(), nil) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, fmt.Errorf("failed to create request: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Добавляем заголовок авторизации |  | ||||||
| 	req.Header.Set("Authorization", "Bearer "+c.apiKey) |  | ||||||
| 	req.Header.Set("Content-Type", "application/json;charset=utf-8") |  | ||||||
|  |  | ||||||
| 	log.Printf("Making request to TMDB: %s %s", method, u.String()) |  | ||||||
|  |  | ||||||
| 	// Выполняем запрос |  | ||||||
| 	resp, err := c.httpClient.Do(req) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, fmt.Errorf("failed to make request: %v", err) |  | ||||||
| 	} |  | ||||||
| 	defer resp.Body.Close() |  | ||||||
|  |  | ||||||
| 	// Проверяем статус ответа |  | ||||||
| 	if resp.StatusCode != http.StatusOK { |  | ||||||
| 		body, _ := io.ReadAll(resp.Body) |  | ||||||
| 		return nil, fmt.Errorf("TMDB API error: status=%d body=%s", resp.StatusCode, string(body)) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Читаем тело ответа |  | ||||||
| 	body, err := io.ReadAll(resp.Body) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, fmt.Errorf("failed to read response body: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return body, nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetImageURL возвращает полный URL изображения |  | ||||||
| func (c *Client) GetImageURL(path string, size string) string { |  | ||||||
| 	if path == "" { |  | ||||||
| 		return "" |  | ||||||
| 	} |  | ||||||
| 	return fmt.Sprintf("%s/%s%s", imageBaseURL, size, path) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetPopular получает список популярных фильмов |  | ||||||
| func (c *Client) GetPopular(page string) (*MoviesResponse, error) { |  | ||||||
| 	params := url.Values{} |  | ||||||
| 	params.Set("page", page) |  | ||||||
|  |  | ||||||
| 	body, err := c.makeRequest(http.MethodGet, "movie/popular", params) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var response MoviesResponse |  | ||||||
| 	if err := json.Unmarshal(body, &response); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error decoding response: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return &response, nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetMovie получает информацию о конкретном фильме |  | ||||||
| func (c *Client) GetMovie(id string) (*MovieDetails, error) { |  | ||||||
| 	body, err := c.makeRequest(http.MethodGet, fmt.Sprintf("movie/%s", id), nil) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var movie MovieDetails |  | ||||||
| 	if err := json.Unmarshal(body, &movie); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error decoding response: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return &movie, nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // SearchMovies ищет фильмы по запросу с поддержкой русского языка |  | ||||||
| func (c *Client) SearchMovies(query string, page string) (*MoviesResponse, error) { |  | ||||||
| 	params := url.Values{} |  | ||||||
| 	params.Set("query", query) |  | ||||||
| 	params.Set("page", page) |  | ||||||
| 	params.Set("language", "ru-RU")      // Добавляем русский язык |  | ||||||
| 	params.Set("region", "RU")           // Добавляем русский регион |  | ||||||
| 	params.Set("include_adult", "false") // Исключаем взрослый контент |  | ||||||
|  |  | ||||||
| 	body, err := c.makeRequest(http.MethodGet, "search/movie", params) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var response MoviesResponse |  | ||||||
| 	if err := json.Unmarshal(body, &response); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error decoding response: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Фильтруем результаты |  | ||||||
| 	filteredResults := make([]Movie, 0) |  | ||||||
| 	for _, movie := range response.Results { |  | ||||||
| 		// Проверяем, что у фильма есть постер и описание |  | ||||||
| 		if movie.PosterPath != "" && movie.Overview != "" { |  | ||||||
| 			// Проверяем, что рейтинг больше 0 |  | ||||||
| 			if movie.VoteAverage > 0 { |  | ||||||
| 				filteredResults = append(filteredResults, movie) |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Обновляем результаты |  | ||||||
| 	response.Results = filteredResults |  | ||||||
| 	response.TotalResults = len(filteredResults) |  | ||||||
|  |  | ||||||
| 	return &response, nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetTopRated получает список лучших фильмов |  | ||||||
| func (c *Client) GetTopRated(page string) (*MoviesResponse, error) { |  | ||||||
| 	params := url.Values{} |  | ||||||
| 	params.Set("page", page) |  | ||||||
|  |  | ||||||
| 	body, err := c.makeRequest(http.MethodGet, "movie/top_rated", params) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var response MoviesResponse |  | ||||||
| 	if err := json.Unmarshal(body, &response); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error decoding response: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return &response, nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetUpcoming получает список предстоящих фильмов |  | ||||||
| func (c *Client) GetUpcoming(page string) (*MoviesResponse, error) { |  | ||||||
| 	params := url.Values{} |  | ||||||
| 	params.Set("page", page) |  | ||||||
|  |  | ||||||
| 	body, err := c.makeRequest(http.MethodGet, "movie/upcoming", params) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var response MoviesResponse |  | ||||||
| 	if err := json.Unmarshal(body, &response); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error decoding response: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return &response, nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // DiscoverMovies получает список фильмов по фильтрам |  | ||||||
| func (c *Client) DiscoverMovies(page string) (*MoviesResponse, error) { |  | ||||||
| 	params := url.Values{} |  | ||||||
| 	params.Set("page", page) |  | ||||||
|  |  | ||||||
| 	body, err := c.makeRequest(http.MethodGet, "discover/movie", params) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var response MoviesResponse |  | ||||||
| 	if err := json.Unmarshal(body, &response); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error decoding response: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return &response, nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // DiscoverTV получает список сериалов по фильтрам |  | ||||||
| func (c *Client) DiscoverTV(page string) (*MoviesResponse, error) { |  | ||||||
| 	params := url.Values{} |  | ||||||
| 	params.Set("page", page) |  | ||||||
|  |  | ||||||
| 	body, err := c.makeRequest(http.MethodGet, "discover/tv", params) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var response MoviesResponse |  | ||||||
| 	if err := json.Unmarshal(body, &response); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error decoding response: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return &response, nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // ExternalIDs содержит внешние идентификаторы фильма/сериала |  | ||||||
| type ExternalIDs struct { |  | ||||||
| 	ID          int    `json:"id"` |  | ||||||
| 	IMDbID      string `json:"imdb_id"` |  | ||||||
| 	FacebookID  string `json:"facebook_id"` |  | ||||||
| 	InstagramID string `json:"instagram_id"` |  | ||||||
| 	TwitterID   string `json:"twitter_id"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetMovieExternalIDs возвращает внешние идентификаторы фильма |  | ||||||
| func (c *Client) GetMovieExternalIDs(id string) (*ExternalIDs, error) { |  | ||||||
| 	body, err := c.makeRequest(http.MethodGet, fmt.Sprintf("movie/%s/external_ids", id), nil) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var externalIDs ExternalIDs |  | ||||||
| 	if err := json.Unmarshal(body, &externalIDs); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error decoding response: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return &externalIDs, nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // GetTVExternalIDs возвращает внешние идентификаторы сериала |  | ||||||
| func (c *Client) GetTVExternalIDs(id string) (*ExternalIDs, error) { |  | ||||||
| 	body, err := c.makeRequest(http.MethodGet, fmt.Sprintf("tv/%s/external_ids", id), nil) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var externalIDs ExternalIDs |  | ||||||
| 	if err := json.Unmarshal(body, &externalIDs); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error decoding response: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return &externalIDs, nil |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // TVSearchResults содержит результаты поиска сериалов |  | ||||||
| type TVSearchResults struct { |  | ||||||
| 	Page         int    `json:"page"` |  | ||||||
| 	TotalResults int    `json:"total_results"` |  | ||||||
| 	TotalPages   int    `json:"total_pages"` |  | ||||||
| 	Results      []TV   `json:"results"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // TV содержит информацию о сериале |  | ||||||
| type TV struct { |  | ||||||
| 	ID               int       `json:"id"` |  | ||||||
| 	Name             string    `json:"name"` |  | ||||||
| 	OriginalName     string    `json:"original_name"` |  | ||||||
| 	Overview         string    `json:"overview"` |  | ||||||
| 	FirstAirDate     string    `json:"first_air_date"` |  | ||||||
| 	PosterPath       string    `json:"poster_path"` |  | ||||||
| 	BackdropPath     string    `json:"backdrop_path"` |  | ||||||
| 	VoteAverage      float64   `json:"vote_average"` |  | ||||||
| 	VoteCount        int       `json:"vote_count"` |  | ||||||
| 	Popularity       float64   `json:"popularity"` |  | ||||||
| 	OriginalLanguage string    `json:"original_language"` |  | ||||||
| 	GenreIDs         []int     `json:"genre_ids"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // SearchTV ищет сериалы в TMDB |  | ||||||
| func (c *Client) SearchTV(query string, page string) (*TVSearchResults, error) { |  | ||||||
| 	params := url.Values{} |  | ||||||
| 	params.Set("query", query) |  | ||||||
| 	params.Set("page", page) |  | ||||||
|  |  | ||||||
| 	body, err := c.makeRequest(http.MethodGet, "search/tv", params) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	var results TVSearchResults |  | ||||||
| 	if err := json.Unmarshal(body, &results); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error decoding response: %v", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return &results, nil |  | ||||||
| } |  | ||||||
| @@ -1,76 +0,0 @@ | |||||||
| package tmdb |  | ||||||
|  |  | ||||||
| // MoviesResponse представляет ответ от TMDB API со списком фильмов |  | ||||||
| type MoviesResponse struct { |  | ||||||
| 	Page         int     `json:"page"` |  | ||||||
| 	Results      []Movie `json:"results"` |  | ||||||
| 	TotalPages   int     `json:"total_pages"` |  | ||||||
| 	TotalResults int     `json:"total_results"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Movie представляет информацию о фильме |  | ||||||
| type Movie struct { |  | ||||||
| 	Adult            bool     `json:"adult"` |  | ||||||
| 	BackdropPath     string   `json:"backdrop_path"` |  | ||||||
| 	GenreIDs         []int    `json:"genre_ids"` |  | ||||||
| 	ID               int      `json:"id"` |  | ||||||
| 	OriginalLanguage string   `json:"original_language"` |  | ||||||
| 	OriginalTitle    string   `json:"original_title"` |  | ||||||
| 	Overview         string   `json:"overview"` |  | ||||||
| 	Popularity       float64  `json:"popularity"` |  | ||||||
| 	PosterPath       string   `json:"poster_path"` |  | ||||||
| 	ReleaseDate      string   `json:"release_date"` |  | ||||||
| 	Title            string   `json:"title"` |  | ||||||
| 	Video            bool     `json:"video"` |  | ||||||
| 	VoteAverage      float64  `json:"vote_average"` |  | ||||||
| 	VoteCount        int      `json:"vote_count"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Genre представляет жанр фильма |  | ||||||
| type Genre struct { |  | ||||||
| 	ID   int    `json:"id"` |  | ||||||
| 	Name string `json:"name"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // Collection представляет коллекцию фильмов |  | ||||||
| type Collection struct { |  | ||||||
| 	ID           int    `json:"id"` |  | ||||||
| 	Name         string `json:"name"` |  | ||||||
| 	PosterPath   string `json:"poster_path"` |  | ||||||
| 	BackdropPath string `json:"backdrop_path"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // ProductionCompany представляет компанию-производителя |  | ||||||
| type ProductionCompany struct { |  | ||||||
| 	ID       int    `json:"id"` |  | ||||||
| 	LogoPath string `json:"logo_path"` |  | ||||||
| 	Name     string `json:"name"` |  | ||||||
| 	Country  string `json:"origin_country"` |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // MovieDetails представляет детальную информацию о фильме |  | ||||||
| type MovieDetails struct { |  | ||||||
| 	Adult               bool                 `json:"adult"` |  | ||||||
| 	BackdropPath        string              `json:"backdrop_path"` |  | ||||||
| 	BelongsToCollection *Collection         `json:"belongs_to_collection"` |  | ||||||
| 	Budget              int                 `json:"budget"` |  | ||||||
| 	Genres             []Genre             `json:"genres"` |  | ||||||
| 	Homepage           string              `json:"homepage"` |  | ||||||
| 	ID                 int                 `json:"id"` |  | ||||||
| 	IMDbID             string              `json:"imdb_id"` |  | ||||||
| 	OriginalLanguage   string              `json:"original_language"` |  | ||||||
| 	OriginalTitle      string              `json:"original_title"` |  | ||||||
| 	Overview           string              `json:"overview"` |  | ||||||
| 	Popularity         float64             `json:"popularity"` |  | ||||||
| 	PosterPath         string              `json:"poster_path"` |  | ||||||
| 	ProductionCompanies []ProductionCompany `json:"production_companies"` |  | ||||||
| 	ReleaseDate        string              `json:"release_date"` |  | ||||||
| 	Revenue            int                 `json:"revenue"` |  | ||||||
| 	Runtime            int                 `json:"runtime"` |  | ||||||
| 	Status             string              `json:"status"` |  | ||||||
| 	Tagline            string              `json:"tagline"` |  | ||||||
| 	Title              string              `json:"title"` |  | ||||||
| 	Video              bool                `json:"video"` |  | ||||||
| 	VoteAverage        float64             `json:"vote_average"` |  | ||||||
| 	VoteCount          int                 `json:"vote_count"` |  | ||||||
| } |  | ||||||
							
								
								
									
										257
									
								
								main.go
									
									
									
									
									
								
							
							
						
						
									
										257
									
								
								main.go
									
									
									
									
									
								
							| @@ -1,125 +1,166 @@ | |||||||
| package main | package main | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"log" | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
| 	"os" | 	"os" | ||||||
|  |  | ||||||
| 	"neomovies-api/internal/api" | 	"github.com/gorilla/handlers" | ||||||
|  | 	"github.com/gorilla/mux" | ||||||
|  | 	"github.com/joho/godotenv" | ||||||
|  |  | ||||||
| 	"github.com/gin-contrib/cors" | 	"neomovies-api/pkg/config" | ||||||
| 	"github.com/gin-gonic/gin" | 	"neomovies-api/pkg/database" | ||||||
| 	swaggerFiles "github.com/swaggo/files" | 	appHandlers "neomovies-api/pkg/handlers" | ||||||
| 	ginSwagger "github.com/swaggo/gin-swagger" | 	"neomovies-api/pkg/middleware" | ||||||
|  | 	"neomovies-api/pkg/monitor" | ||||||
| 	_ "neomovies-api/docs" | 	"neomovies-api/pkg/services" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // @title Neo Movies API |  | ||||||
| // @version 1.0 |  | ||||||
| // @description API для работы с фильмами |  | ||||||
| // @host localhost:8080 |  | ||||||
| // @BasePath / |  | ||||||
| func main() { | func main() { | ||||||
| 	// Устанавливаем переменные окружения | 	if err := godotenv.Load(); err != nil { | ||||||
| 	os.Setenv("GIN_MODE", "debug") | 		_ = err | ||||||
| 	os.Setenv("PORT", "8080") |  | ||||||
| 	os.Setenv("TMDB_ACCESS_TOKEN", "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI4ZmU3ODhlYmI5ZDAwNjZiNjQ2MWZhNzk5M2MyMzcxYiIsIm5iZiI6MTcyMzQwMTM3My4yMDgsInN1YiI6IjY2YjkwNDlkNzU4ZDQxOTQwYzA3NjlhNSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.x50tvcWDdBTEhtwRb3dE7aEe9qu4sXV_qOjLMn_Vmew") |  | ||||||
|  |  | ||||||
| 	// Инициализируем TMDB клиент с CommsOne DNS |  | ||||||
| 	log.Println("Initializing TMDB client with CommsOne DNS") |  | ||||||
| 	api.InitTMDBClient(os.Getenv("TMDB_ACCESS_TOKEN")) |  | ||||||
|  |  | ||||||
| 	// Устанавливаем режим Gin |  | ||||||
| 	gin.SetMode(os.Getenv("GIN_MODE")) |  | ||||||
|  |  | ||||||
| 	// Создаем роутер |  | ||||||
| 	r := gin.Default() |  | ||||||
|  |  | ||||||
| 	// Настраиваем CORS |  | ||||||
| 	r.Use(cors.New(cors.Config{ |  | ||||||
| 		AllowOrigins: []string{"*"}, |  | ||||||
| 		AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, |  | ||||||
| 		AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, |  | ||||||
| 	})) |  | ||||||
|  |  | ||||||
| 	// Swagger документация |  | ||||||
| 	r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) |  | ||||||
|  |  | ||||||
| 	// Health check |  | ||||||
| 	r.GET("/health", api.HealthCheck) |  | ||||||
|  |  | ||||||
| 	// Movies API |  | ||||||
| 	movies := r.Group("/movies") |  | ||||||
| 	{ |  | ||||||
| 		movies.GET("/popular", api.GetPopularMovies) |  | ||||||
| 		movies.GET("/search", api.SearchMovies) |  | ||||||
| 		movies.GET("/top-rated", api.GetTopRatedMovies) |  | ||||||
| 		movies.GET("/upcoming", api.GetUpcomingMovies) |  | ||||||
| 		movies.GET("/:id", api.GetMovie) |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Bridge API | 	cfg := config.New() | ||||||
| 	bridge := r.Group("/bridge") |  | ||||||
| 	{ | 	db, err := database.Connect(cfg.MongoURI, cfg.MongoDBName) | ||||||
| 		// TMDB endpoints | 	if err != nil { | ||||||
| 		tmdb := bridge.Group("/tmdb") | 		fmt.Printf("❌ Failed to connect to database: %v\n", err) | ||||||
| 		{ | 		os.Exit(1) | ||||||
| 			// Movie endpoints | 	} | ||||||
| 			movie := tmdb.Group("/movie") | 	defer database.Disconnect() | ||||||
| 			{ |  | ||||||
| 				movie.GET("/popular", api.GetTMDBPopularMovies) | 	tmdbService := services.NewTMDBService(cfg.TMDBAccessToken) | ||||||
| 				movie.GET("/top_rated", api.GetTMDBTopRatedMovies) | 	emailService := services.NewEmailService(cfg) | ||||||
| 				movie.GET("/upcoming", api.GetTMDBUpcomingMovies) | 	authService := services.NewAuthService(db, cfg.JWTSecret, emailService, cfg.BaseURL, cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.GoogleRedirectURL, cfg.FrontendURL) | ||||||
| 				movie.GET("/:id", api.GetTMDBMovie) |  | ||||||
| 				movie.GET("/:id/external_ids", api.GetTMDBMovieExternalIDs) | 	movieService := services.NewMovieService(db, tmdbService) | ||||||
|  | 	tvService := services.NewTVService(db, tmdbService) | ||||||
|  | 	favoritesService := services.NewFavoritesService(db, tmdbService) | ||||||
|  | 	torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey) | ||||||
|  | 	reactionsService := services.NewReactionsService(db) | ||||||
|  |  | ||||||
|  | 	authHandler := appHandlers.NewAuthHandler(authService) | ||||||
|  | 	movieHandler := appHandlers.NewMovieHandler(movieService) | ||||||
|  | 	tvHandler := appHandlers.NewTVHandler(tvService) | ||||||
|  | 	favoritesHandler := appHandlers.NewFavoritesHandler(favoritesService, cfg) | ||||||
|  | 	docsHandler := appHandlers.NewDocsHandler() | ||||||
|  | 	searchHandler := appHandlers.NewSearchHandler(tmdbService) | ||||||
|  | 	categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService) | ||||||
|  | 	playersHandler := appHandlers.NewPlayersHandler(cfg) | ||||||
|  | 	torrentsHandler := appHandlers.NewTorrentsHandler(torrentService, tmdbService) | ||||||
|  | 	reactionsHandler := appHandlers.NewReactionsHandler(reactionsService) | ||||||
|  | 	imagesHandler := appHandlers.NewImagesHandler() | ||||||
|  |  | ||||||
|  | 	r := mux.NewRouter() | ||||||
|  |  | ||||||
|  | 	r.HandleFunc("/", docsHandler.ServeDocs).Methods("GET") | ||||||
|  | 	r.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api := r.PathPrefix("/api/v1").Subrouter() | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/health", appHandlers.HealthCheck).Methods("GET") | ||||||
|  | 	api.HandleFunc("/auth/register", authHandler.Register).Methods("POST") | ||||||
|  | 	api.HandleFunc("/auth/login", authHandler.Login).Methods("POST") | ||||||
|  | 	api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST") | ||||||
|  | 	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") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET") | ||||||
|  | 	api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET") | ||||||
|  | 	api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET") | ||||||
|  | 	api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET") | ||||||
|  | 	api.HandleFunc("/players/vibix/{imdb_id}", playersHandler.GetVibixPlayer).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET") | ||||||
|  | 	api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") | ||||||
|  | 	api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET") | ||||||
|  | 	api.HandleFunc("/torrents/anime", torrentsHandler.SearchAnime).Methods("GET") | ||||||
|  | 	api.HandleFunc("/torrents/seasons", torrentsHandler.GetAvailableSeasons).Methods("GET") | ||||||
|  | 	api.HandleFunc("/torrents/search", torrentsHandler.SearchByQuery).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/reactions/{mediaType}/{mediaId}/counts", reactionsHandler.GetReactionCounts).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/images/{size}/{path:.*}", imagesHandler.GetImage).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/movies/search", movieHandler.Search).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/popular", movieHandler.Popular).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/top-rated", movieHandler.TopRated).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/upcoming", movieHandler.Upcoming).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/now-playing", movieHandler.NowPlaying).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/{id}", movieHandler.GetByID).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/{id}/recommendations", movieHandler.GetRecommendations).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/{id}/similar", movieHandler.GetSimilar).Methods("GET") | ||||||
|  | 	api.HandleFunc("/movies/{id}/external-ids", movieHandler.GetExternalIDs).Methods("GET") | ||||||
|  |  | ||||||
|  | 	api.HandleFunc("/tv/search", tvHandler.Search).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/popular", tvHandler.Popular).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/top-rated", tvHandler.TopRated).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/on-the-air", tvHandler.OnTheAir).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/airing-today", tvHandler.AiringToday).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/{id}", tvHandler.GetByID).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/{id}/recommendations", tvHandler.GetRecommendations).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/{id}/similar", tvHandler.GetSimilar).Methods("GET") | ||||||
|  | 	api.HandleFunc("/tv/{id}/external-ids", tvHandler.GetExternalIDs).Methods("GET") | ||||||
|  |  | ||||||
|  | 	protected := api.PathPrefix("").Subrouter() | ||||||
|  | 	protected.Use(middleware.JWTAuth(cfg.JWTSecret)) | ||||||
|  |  | ||||||
|  | 	protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET") | ||||||
|  | 	protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST") | ||||||
|  | 	protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE") | ||||||
|  | 	protected.HandleFunc("/favorites/{id}/check", favoritesHandler.CheckIsFavorite).Methods("GET") | ||||||
|  |  | ||||||
|  | 	protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET") | ||||||
|  | 	protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") | ||||||
|  | 	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") | ||||||
|  | 	protected.HandleFunc("/reactions/{mediaType}/{mediaId}", reactionsHandler.RemoveReaction).Methods("DELETE") | ||||||
|  | 	protected.HandleFunc("/reactions/my", reactionsHandler.GetMyReactions).Methods("GET") | ||||||
|  |  | ||||||
|  | 	corsHandler := handlers.CORS( | ||||||
|  | 		handlers.AllowedOrigins([]string{"*"}), | ||||||
|  | 		handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), | ||||||
|  | 		handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With", "X-CSRF-Token"}), | ||||||
|  | 		handlers.AllowCredentials(), | ||||||
|  | 		handlers.ExposedHeaders([]string{"Authorization", "Content-Type"}), | ||||||
|  | 	) | ||||||
|  |  | ||||||
|  | 	var finalHandler http.Handler | ||||||
|  | 	if cfg.NodeEnv == "development" { | ||||||
|  | 		r.Use(monitor.RequestMonitor()) | ||||||
|  | 		finalHandler = corsHandler(r) | ||||||
|  |  | ||||||
|  | 		fmt.Println("\n🚀 NeoMovies API Server") | ||||||
|  | 		fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") | ||||||
|  | 		fmt.Printf("📡 Server: http://localhost:%s\n", cfg.Port) | ||||||
|  | 		fmt.Printf("📚 Docs:   http://localhost:%s/\n", cfg.Port) | ||||||
|  | 		fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") | ||||||
|  | 		fmt.Printf("%-6s %-3s │ %-60s │ %8s\n", "METHOD", "CODE", "ENDPOINT", "TIME") | ||||||
|  | 		fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") | ||||||
|  | 	} else { | ||||||
|  | 		finalHandler = corsHandler(r) | ||||||
|  | 		fmt.Printf("✅ Server starting on port %s\n", cfg.Port) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 			// Search endpoints | 	port := cfg.Port | ||||||
| 			search := tmdb.Group("/search") | 	if port == "" { | ||||||
| 			{ | 		port = "3000" | ||||||
| 				search.GET("/movie", api.SearchTMDBMovies) |  | ||||||
| 				search.GET("/tv", api.SearchTMDBTV) |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 			// TV endpoints | 	if err := http.ListenAndServe(":"+port, finalHandler); err != nil { | ||||||
| 			tv := tmdb.Group("/tv") | 		fmt.Printf("❌ Server failed to start: %v\n", err) | ||||||
| 			{ | 		os.Exit(1) | ||||||
| 				tv.GET("/:id/external_ids", api.GetTMDBTVExternalIDs) |  | ||||||
| 			} |  | ||||||
|  |  | ||||||
| 			// Discover endpoints |  | ||||||
| 			discover := tmdb.Group("/discover") |  | ||||||
| 			{ |  | ||||||
| 				discover.GET("/movie", api.DiscoverMovies) |  | ||||||
| 				discover.GET("/tv", api.DiscoverTV) |  | ||||||
| 			} |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Admin API |  | ||||||
| 	admin := r.Group("/admin") |  | ||||||
| 	{ |  | ||||||
| 		// Movies endpoints |  | ||||||
| 		adminMovies := admin.Group("/movies") |  | ||||||
| 		{ |  | ||||||
| 			adminMovies.GET("", api.GetAdminMovies) |  | ||||||
| 			adminMovies.POST("/toggle-visibility", api.ToggleMovieVisibility) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		// Users endpoints |  | ||||||
| 		adminUsers := admin.Group("/users") |  | ||||||
| 		{ |  | ||||||
| 			adminUsers.GET("", api.GetUsers) |  | ||||||
| 			adminUsers.POST("/create", api.CreateUser) |  | ||||||
| 			adminUsers.POST("/toggle-admin", api.ToggleAdmin) |  | ||||||
| 			adminUsers.POST("/send-verification", api.SendVerification) |  | ||||||
| 			adminUsers.POST("/verify-code", api.VerifyCode) |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// Запускаем сервер |  | ||||||
| 	port := os.Getenv("PORT") |  | ||||||
| 	if err := r.Run(":" + port); err != nil { |  | ||||||
| 		log.Fatal(err) |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										72
									
								
								pkg/config/config.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								pkg/config/config.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | |||||||
|  | package config | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"log" | ||||||
|  | 	"os" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type Config struct { | ||||||
|  | 	MongoURI           string | ||||||
|  | 	MongoDBName        string | ||||||
|  | 	TMDBAccessToken    string | ||||||
|  | 	JWTSecret          string | ||||||
|  | 	Port               string | ||||||
|  | 	BaseURL            string | ||||||
|  | 	NodeEnv            string | ||||||
|  | 	GmailUser          string | ||||||
|  | 	GmailPassword      string | ||||||
|  | 	LumexURL           string | ||||||
|  | 	AllohaToken        string | ||||||
|  | 	RedAPIBaseURL      string | ||||||
|  | 	RedAPIKey          string | ||||||
|  | 	GoogleClientID     string | ||||||
|  | 	GoogleClientSecret string | ||||||
|  | 	GoogleRedirectURL  string | ||||||
|  | 	FrontendURL        string | ||||||
|  | 	VibixHost          string | ||||||
|  | 	VibixToken         string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func New() *Config { | ||||||
|  | 	mongoURI := getMongoURI() | ||||||
|  |  | ||||||
|  | 	return &Config{ | ||||||
|  | 		MongoURI:           mongoURI, | ||||||
|  | 		MongoDBName:        getEnv(EnvMongoDBName, DefaultMongoDBName), | ||||||
|  | 		TMDBAccessToken:    getEnv(EnvTMDBAccessToken, ""), | ||||||
|  | 		JWTSecret:          getEnv(EnvJWTSecret, DefaultJWTSecret), | ||||||
|  | 		Port:               getEnv(EnvPort, DefaultPort), | ||||||
|  | 		BaseURL:            getEnv(EnvBaseURL, DefaultBaseURL), | ||||||
|  | 		NodeEnv:            getEnv(EnvNodeEnv, DefaultNodeEnv), | ||||||
|  | 		GmailUser:          getEnv(EnvGmailUser, ""), | ||||||
|  | 		GmailPassword:      getEnv(EnvGmailPassword, ""), | ||||||
|  | 		LumexURL:           getEnv(EnvLumexURL, ""), | ||||||
|  | 		AllohaToken:        getEnv(EnvAllohaToken, ""), | ||||||
|  | 		RedAPIBaseURL:      getEnv(EnvRedAPIBaseURL, DefaultRedAPIBase), | ||||||
|  | 		RedAPIKey:          getEnv(EnvRedAPIKey, ""), | ||||||
|  | 		GoogleClientID:     getEnv(EnvGoogleClientID, ""), | ||||||
|  | 		GoogleClientSecret: getEnv(EnvGoogleClientSecret, ""), | ||||||
|  | 		GoogleRedirectURL:  getEnv(EnvGoogleRedirectURL, ""), | ||||||
|  | 		FrontendURL:        getEnv(EnvFrontendURL, ""), | ||||||
|  | 		VibixHost:          getEnv(EnvVibixHost, DefaultVibixHost), | ||||||
|  | 		VibixToken:         getEnv(EnvVibixToken, ""), | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func getMongoURI() string { | ||||||
|  | 	for _, envVar := range []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} { | ||||||
|  | 		if value := os.Getenv(envVar); value != "" { | ||||||
|  | 			log.Printf("DEBUG: Using %s for MongoDB connection", envVar) | ||||||
|  | 			return value | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	log.Printf("DEBUG: No MongoDB URI environment variable found") | ||||||
|  | 	return "" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func getEnv(key, defaultValue string) string { | ||||||
|  | 	if value := os.Getenv(key); value != "" { | ||||||
|  | 		return value | ||||||
|  | 	} | ||||||
|  | 	return defaultValue | ||||||
|  | } | ||||||
							
								
								
									
										36
									
								
								pkg/config/vars.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								pkg/config/vars.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | |||||||
|  | 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" | ||||||
|  |  | ||||||
|  | 	// Default values | ||||||
|  | 	DefaultJWTSecret   = "your-secret-key" | ||||||
|  | 	DefaultPort        = "3000" | ||||||
|  | 	DefaultBaseURL     = "http://localhost:3000" | ||||||
|  | 	DefaultNodeEnv     = "development" | ||||||
|  | 	DefaultRedAPIBase  = "http://redapi.cfhttp.top" | ||||||
|  | 	DefaultMongoDBName = "database" | ||||||
|  | 	DefaultVibixHost   = "https://vibix.org" | ||||||
|  |  | ||||||
|  | 	// Static constants | ||||||
|  | 	TMDBImageBaseURL = "https://image.tmdb.org/t/p" | ||||||
|  | 	CubAPIBaseURL    = "https://cub.rip/api" | ||||||
|  | ) | ||||||
							
								
								
									
										41
									
								
								pkg/database/connection.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								pkg/database/connection.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | package database | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"go.mongodb.org/mongo-driver/mongo" | ||||||
|  | 	"go.mongodb.org/mongo-driver/mongo/options" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | var client *mongo.Client | ||||||
|  |  | ||||||
|  | func Connect(uri, dbName string) (*mongo.Database, error) { | ||||||
|  | 	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||||||
|  | 	defer cancel() | ||||||
|  |  | ||||||
|  | 	var err error | ||||||
|  | 	client, err = mongo.Connect(ctx, options.Client().ApplyURI(uri)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err = client.Ping(ctx, nil); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return client.Database(dbName), nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func Disconnect() error { | ||||||
|  | 	if client == nil { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||||||
|  | 	defer cancel() | ||||||
|  |  | ||||||
|  | 	return client.Disconnect(ctx) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func GetClient() *mongo.Client { return client } | ||||||
							
								
								
									
										309
									
								
								pkg/handlers/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										309
									
								
								pkg/handlers/auth.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,309 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"net/http" | ||||||
|  | 	"strings" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"go.mongodb.org/mongo-driver/bson" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/middleware" | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | 	"neomovies-api/pkg/services" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type AuthHandler struct { | ||||||
|  | 	authService *services.AuthService | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewAuthHandler(authService *services.AuthService) *AuthHandler { | ||||||
|  | 	return &AuthHandler{authService: authService} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	var req models.RegisterRequest | ||||||
|  | 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||||||
|  | 		http.Error(w, "Invalid request body", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response, err := h.authService.Register(req) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusConflict) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	w.WriteHeader(http.StatusCreated) | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "User registered successfully"}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	var req models.LoginRequest | ||||||
|  | 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||||||
|  | 		http.Error(w, "Invalid request body", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Получаем информацию о клиенте для refresh токена | ||||||
|  | 	userAgent := r.Header.Get("User-Agent") | ||||||
|  | 	ipAddress := r.RemoteAddr | ||||||
|  | 	if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { | ||||||
|  | 		ipAddress = forwarded | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response, err := h.authService.LoginWithTokens(req, userAgent, ipAddress) | ||||||
|  | 	if err != nil { | ||||||
|  | 		statusCode := http.StatusBadRequest | ||||||
|  | 		if err.Error() == "Account not activated. Please verify your email." { | ||||||
|  | 			statusCode = http.StatusForbidden | ||||||
|  | 		} | ||||||
|  | 		http.Error(w, err.Error(), statusCode) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "Login successful"}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *AuthHandler) GoogleLogin(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	state := generateState() | ||||||
|  | 	http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Path: "/", Expires: time.Now().Add(10 * time.Minute)}) | ||||||
|  | 	url, err := h.authService.GetGoogleLoginURL(state) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	http.Redirect(w, r, url, http.StatusFound) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *AuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	q := r.URL.Query() | ||||||
|  | 	state := q.Get("state") | ||||||
|  | 	code := q.Get("code") | ||||||
|  | 	preferJSON := q.Get("response") == "json" || strings.Contains(r.Header.Get("Accept"), "application/json") | ||||||
|  | 	cookie, _ := r.Cookie("oauth_state") | ||||||
|  | 	if cookie == nil || cookie.Value != state || code == "" { | ||||||
|  | 		if preferJSON { | ||||||
|  | 			w.Header().Set("Content-Type", "application/json") | ||||||
|  | 			w.WriteHeader(http.StatusBadRequest) | ||||||
|  | 			json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: "invalid oauth state"}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		redirectURL, ok := h.authService.BuildFrontendRedirect("", "invalid_state") | ||||||
|  | 		if ok { | ||||||
|  | 			http.Redirect(w, r, redirectURL, http.StatusFound) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		http.Error(w, "invalid oauth state", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	resp, err := h.authService.HandleGoogleCallback(r.Context(), code) | ||||||
|  | 	if err != nil { | ||||||
|  | 		if preferJSON { | ||||||
|  | 			w.Header().Set("Content-Type", "application/json") | ||||||
|  | 			w.WriteHeader(http.StatusBadRequest) | ||||||
|  | 			json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: err.Error()}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		redirectURL, ok := h.authService.BuildFrontendRedirect("", "auth_failed") | ||||||
|  | 		if ok { | ||||||
|  | 			http.Redirect(w, r, redirectURL, http.StatusFound) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		http.Error(w, err.Error(), http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if preferJSON { | ||||||
|  | 		w.Header().Set("Content-Type", "application/json") | ||||||
|  | 		json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	redirectURL, ok := h.authService.BuildFrontendRedirect(resp.Token, "") | ||||||
|  | 	if ok { | ||||||
|  | 		http.Redirect(w, r, redirectURL, http.StatusFound) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	user, err := h.authService.GetUserByID(userID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "User not found", http.StatusNotFound) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var updates map[string]interface{} | ||||||
|  | 	if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { | ||||||
|  | 		http.Error(w, "Invalid request body", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	delete(updates, "password") | ||||||
|  | 	delete(updates, "email") | ||||||
|  | 	delete(updates, "_id") | ||||||
|  | 	delete(updates, "created_at") | ||||||
|  |  | ||||||
|  | 	user, err := h.authService.UpdateUser(userID, bson.M(updates)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Failed to update user", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user, Message: "Profile updated successfully"}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err := h.authService.DeleteAccount(r.Context(), userID); err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Account deleted successfully"}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	var req models.VerifyEmailRequest | ||||||
|  | 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||||||
|  | 		http.Error(w, "Invalid request body", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response, err := h.authService.VerifyEmail(req) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(response) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	var req models.ResendCodeRequest | ||||||
|  | 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||||||
|  | 		http.Error(w, "Invalid request body", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response, err := h.authService.ResendVerificationCode(req) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(response) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // RefreshToken refreshes an access token using a refresh token | ||||||
|  | func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	var req models.RefreshTokenRequest | ||||||
|  | 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||||||
|  | 		http.Error(w, "Invalid request body", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Получаем информацию о клиенте | ||||||
|  | 	userAgent := r.Header.Get("User-Agent") | ||||||
|  | 	ipAddress := r.RemoteAddr | ||||||
|  | 	if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { | ||||||
|  | 		ipAddress = forwarded | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	tokenPair, err := h.authService.RefreshAccessToken(req.RefreshToken, userAgent, ipAddress) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusUnauthorized) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    tokenPair, | ||||||
|  | 		Message: "Token refreshed successfully", | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // RevokeRefreshToken revokes a specific refresh token | ||||||
|  | func (h *AuthHandler) RevokeRefreshToken(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var req models.RefreshTokenRequest | ||||||
|  | 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||||||
|  | 		http.Error(w, "Invalid request body", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	err := h.authService.RevokeRefreshToken(userID, req.RefreshToken) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Message: "Refresh token revoked successfully", | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // RevokeAllRefreshTokens revokes all refresh tokens for the current user | ||||||
|  | func (h *AuthHandler) RevokeAllRefreshTokens(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	err := h.authService.RevokeAllRefreshTokens(userID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Message: "All refresh tokens revoked successfully", | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // helpers | ||||||
|  | func generateState() string { return uuidNew() } | ||||||
							
								
								
									
										7
									
								
								pkg/handlers/auth_helpers.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								pkg/handlers/auth_helpers.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"github.com/google/uuid" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func uuidNew() string { return uuid.New().String() } | ||||||
							
								
								
									
										122
									
								
								pkg/handlers/categories.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								pkg/handlers/categories.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,122 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"net/http" | ||||||
|  | 	"strconv" | ||||||
|  |  | ||||||
|  | 	"github.com/gorilla/mux" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | 	"neomovies-api/pkg/services" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type CategoriesHandler struct { | ||||||
|  | 	tmdbService *services.TMDBService | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewCategoriesHandler(tmdbService *services.TMDBService) *CategoriesHandler { | ||||||
|  | 	return &CategoriesHandler{ | ||||||
|  | 		tmdbService: tmdbService, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type Category struct { | ||||||
|  | 	ID   int    `json:"id"` | ||||||
|  | 	Name string `json:"name"` | ||||||
|  | 	Slug string `json:"slug"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *CategoriesHandler) GetCategories(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	// Получаем все жанры | ||||||
|  | 	genresResponse, err := h.tmdbService.GetAllGenres() | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Преобразуем жанры в категории | ||||||
|  | 	var categories []Category | ||||||
|  | 	for _, genre := range genresResponse.Genres { | ||||||
|  | 		slug := generateSlug(genre.Name) | ||||||
|  | 		categories = append(categories, Category{ | ||||||
|  | 			ID:   genre.ID, | ||||||
|  | 			Name: genre.Name, | ||||||
|  | 			Slug: slug, | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    categories, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *CategoriesHandler) GetMediaByCategory(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	categoryID, err := strconv.Atoi(vars["id"]) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Invalid category ID", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  | 	if language == "" { | ||||||
|  | 		language = "ru-RU" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	mediaType := r.URL.Query().Get("type") | ||||||
|  | 	if mediaType == "" { | ||||||
|  | 		mediaType = "movie" // По умолчанию фильмы для обратной совместимости | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if mediaType != "movie" && mediaType != "tv" { | ||||||
|  | 		http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var data interface{} | ||||||
|  | 	var err2 error | ||||||
|  |  | ||||||
|  | 	if mediaType == "movie" { | ||||||
|  | 		// Используем discover API для получения фильмов по жанру | ||||||
|  | 		data, err2 = h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language) | ||||||
|  | 	} else { | ||||||
|  | 		// Используем discover API для получения сериалов по жанру | ||||||
|  | 		data, err2 = h.tmdbService.DiscoverTVByGenre(categoryID, page, language) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err2 != nil { | ||||||
|  | 		http.Error(w, err2.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    data, | ||||||
|  | 		Message: "Media retrieved successfully", | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Старый метод для обратной совместимости | ||||||
|  | func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	// Просто перенаправляем на новый метод | ||||||
|  | 	h.GetMediaByCategory(w, r) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func generateSlug(name string) string { | ||||||
|  | 	// Простая функция для создания slug из названия | ||||||
|  | 	// В реальном проекте стоит использовать более сложную логику | ||||||
|  | 	result := "" | ||||||
|  | 	for _, char := range name { | ||||||
|  | 		if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') { | ||||||
|  | 			result += string(char) | ||||||
|  | 		} else if char == ' ' { | ||||||
|  | 			result += "-" | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return result | ||||||
|  | } | ||||||
							
								
								
									
										1720
									
								
								pkg/handlers/docs.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1720
									
								
								pkg/handlers/docs.go
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										260
									
								
								pkg/handlers/favorites.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								pkg/handlers/favorites.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,260 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  |  | ||||||
|  | 	"github.com/gorilla/mux" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/config" | ||||||
|  | 	"neomovies-api/pkg/middleware" | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | 	"neomovies-api/pkg/services" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type FavoritesHandler struct { | ||||||
|  | 	favoritesService *services.FavoritesService | ||||||
|  | 	config           *config.Config | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewFavoritesHandler(favoritesService *services.FavoritesService, cfg *config.Config) *FavoritesHandler { | ||||||
|  | 	return &FavoritesHandler{ | ||||||
|  | 		favoritesService: favoritesService, | ||||||
|  | 		config:           cfg, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *FavoritesHandler) GetFavorites(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusUnauthorized) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	favorites, err := h.favoritesService.GetFavorites(userID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Failed to get favorites: "+err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    favorites, | ||||||
|  | 		Message: "Favorites retrieved successfully", | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *FavoritesHandler) AddToFavorites(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusUnauthorized) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	mediaID := vars["id"] | ||||||
|  | 	mediaType := r.URL.Query().Get("type") | ||||||
|  |  | ||||||
|  | 	if mediaID == "" { | ||||||
|  | 		http.Error(w, "Media ID is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if mediaType == "" { | ||||||
|  | 		mediaType = "movie" // По умолчанию фильм для обратной совместимости | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if mediaType != "movie" && mediaType != "tv" { | ||||||
|  | 		http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Получаем информацию о медиа на русском языке | ||||||
|  | 	mediaInfo, err := h.fetchMediaInfoRussian(mediaID, mediaType) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Failed to fetch media information: "+err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	err = h.favoritesService.AddToFavoritesWithInfo(userID, mediaID, mediaType, mediaInfo) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Failed to add to favorites: "+err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Message: "Added to favorites successfully", | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *FavoritesHandler) RemoveFromFavorites(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusUnauthorized) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	mediaID := vars["id"] | ||||||
|  | 	mediaType := r.URL.Query().Get("type") | ||||||
|  |  | ||||||
|  | 	if mediaID == "" { | ||||||
|  | 		http.Error(w, "Media ID is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if mediaType == "" { | ||||||
|  | 		mediaType = "movie" // По умолчанию фильм для обратной совместимости | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if mediaType != "movie" && mediaType != "tv" { | ||||||
|  | 		http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	err := h.favoritesService.RemoveFromFavorites(userID, mediaID, mediaType) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Failed to remove from favorites: "+err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Message: "Removed from favorites successfully", | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *FavoritesHandler) CheckIsFavorite(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusUnauthorized) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	mediaID := vars["id"] | ||||||
|  | 	mediaType := r.URL.Query().Get("type") | ||||||
|  |  | ||||||
|  | 	if mediaID == "" { | ||||||
|  | 		http.Error(w, "Media ID is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if mediaType == "" { | ||||||
|  | 		mediaType = "movie" // По умолчанию фильм для обратной совместимости | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if mediaType != "movie" && mediaType != "tv" { | ||||||
|  | 		http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	isFavorite, err := h.favoritesService.IsFavorite(userID, mediaID, mediaType) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Failed to check favorite status: "+err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    map[string]bool{"isFavorite": isFavorite}, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // fetchMediaInfoRussian получает информацию о медиа на русском языке из TMDB | ||||||
|  | func (h *FavoritesHandler) fetchMediaInfoRussian(mediaID, mediaType string) (*models.MediaInfo, error) { | ||||||
|  | 	var url string | ||||||
|  | 	if mediaType == "movie" { | ||||||
|  | 		url = fmt.Sprintf("https://api.themoviedb.org/3/movie/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken) | ||||||
|  | 	} else { | ||||||
|  | 		url = fmt.Sprintf("https://api.themoviedb.org/3/tv/%s?api_key=%s&language=ru-RU", mediaID, h.config.TMDBAccessToken) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	resp, err := http.Get(url) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to fetch from TMDB: %w", err) | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	if resp.StatusCode != http.StatusOK { | ||||||
|  | 		return nil, fmt.Errorf("TMDB API error: status %d", resp.StatusCode) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	body, err := io.ReadAll(resp.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to read response body: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var tmdbResponse map[string]interface{} | ||||||
|  | 	if err := json.Unmarshal(body, &tmdbResponse); err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to parse TMDB response: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	mediaInfo := &models.MediaInfo{ | ||||||
|  | 		ID:        mediaID, | ||||||
|  | 		MediaType: mediaType, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Заполняем информацию в зависимости от типа медиа | ||||||
|  | 	if mediaType == "movie" { | ||||||
|  | 		if title, ok := tmdbResponse["title"].(string); ok { | ||||||
|  | 			mediaInfo.Title = title | ||||||
|  | 		} | ||||||
|  | 		if originalTitle, ok := tmdbResponse["original_title"].(string); ok { | ||||||
|  | 			mediaInfo.OriginalTitle = originalTitle | ||||||
|  | 		} | ||||||
|  | 		if releaseDate, ok := tmdbResponse["release_date"].(string); ok { | ||||||
|  | 			mediaInfo.ReleaseDate = releaseDate | ||||||
|  | 		} | ||||||
|  | 	} else { | ||||||
|  | 		if name, ok := tmdbResponse["name"].(string); ok { | ||||||
|  | 			mediaInfo.Title = name | ||||||
|  | 		} | ||||||
|  | 		if originalName, ok := tmdbResponse["original_name"].(string); ok { | ||||||
|  | 			mediaInfo.OriginalTitle = originalName | ||||||
|  | 		} | ||||||
|  | 		if firstAirDate, ok := tmdbResponse["first_air_date"].(string); ok { | ||||||
|  | 			mediaInfo.FirstAirDate = firstAirDate | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Общие поля | ||||||
|  | 	if overview, ok := tmdbResponse["overview"].(string); ok { | ||||||
|  | 		mediaInfo.Overview = overview | ||||||
|  | 	} | ||||||
|  | 	if posterPath, ok := tmdbResponse["poster_path"].(string); ok { | ||||||
|  | 		mediaInfo.PosterPath = posterPath | ||||||
|  | 	} | ||||||
|  | 	if backdropPath, ok := tmdbResponse["backdrop_path"].(string); ok { | ||||||
|  | 		mediaInfo.BackdropPath = backdropPath | ||||||
|  | 	} | ||||||
|  | 	if voteAverage, ok := tmdbResponse["vote_average"].(float64); ok { | ||||||
|  | 		mediaInfo.VoteAverage = voteAverage | ||||||
|  | 	} | ||||||
|  | 	if voteCount, ok := tmdbResponse["vote_count"].(float64); ok { | ||||||
|  | 		mediaInfo.VoteCount = int(voteCount) | ||||||
|  | 	} | ||||||
|  | 	if popularity, ok := tmdbResponse["popularity"].(float64); ok { | ||||||
|  | 		mediaInfo.Popularity = popularity | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Жанры | ||||||
|  | 	if genres, ok := tmdbResponse["genres"].([]interface{}); ok { | ||||||
|  | 		for _, genre := range genres { | ||||||
|  | 			if genreMap, ok := genre.(map[string]interface{}); ok { | ||||||
|  | 				if genreID, ok := genreMap["id"].(float64); ok { | ||||||
|  | 					mediaInfo.GenreIDs = append(mediaInfo.GenreIDs, int(genreID)) | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return mediaInfo, nil | ||||||
|  | } | ||||||
							
								
								
									
										29
									
								
								pkg/handlers/health.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								pkg/handlers/health.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"net/http" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | func HealthCheck(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	health := map[string]interface{}{ | ||||||
|  | 		"status":    "OK", | ||||||
|  | 		"timestamp": time.Now().UTC(), | ||||||
|  | 		"service":   "neomovies-api", | ||||||
|  | 		"version":   "2.0.0", | ||||||
|  | 		"uptime":    time.Since(startTime), | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	w.WriteHeader(http.StatusOK) | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Message: "API is running", | ||||||
|  | 		Data:    health, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | var startTime = time.Now() | ||||||
							
								
								
									
										134
									
								
								pkg/handlers/images.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								pkg/handlers/images.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,134 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | 	"os" | ||||||
|  | 	"path/filepath" | ||||||
|  | 	"strings" | ||||||
|  |  | ||||||
|  | 	"github.com/gorilla/mux" | ||||||
|  | 	"neomovies-api/pkg/config" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type ImagesHandler struct{} | ||||||
|  |  | ||||||
|  | func NewImagesHandler() *ImagesHandler { return &ImagesHandler{} } | ||||||
|  |  | ||||||
|  | func (h *ImagesHandler) GetImage(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	size := vars["size"] | ||||||
|  | 	imagePath := vars["path"] | ||||||
|  |  | ||||||
|  | 	if size == "" || imagePath == "" { | ||||||
|  | 		http.Error(w, "Size and path are required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if imagePath == "placeholder.jpg" { | ||||||
|  | 		h.servePlaceholder(w, r) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	validSizes := []string{"w92", "w154", "w185", "w342", "w500", "w780", "original"} | ||||||
|  | 	if !h.isValidSize(size, validSizes) { | ||||||
|  | 		size = "original" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	imageURL := fmt.Sprintf("%s/%s/%s", config.TMDBImageBaseURL, size, imagePath) | ||||||
|  |  | ||||||
|  | 	resp, err := http.Get(imageURL) | ||||||
|  | 	if err != nil { | ||||||
|  | 		h.servePlaceholder(w, r) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	if resp.StatusCode != http.StatusOK { | ||||||
|  | 		h.servePlaceholder(w, r) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if contentType := resp.Header.Get("Content-Type"); contentType != "" { | ||||||
|  | 		w.Header().Set("Content-Type", contentType) | ||||||
|  | 	} | ||||||
|  | 	w.Header().Set("Cache-Control", "public, max-age=31536000") | ||||||
|  |  | ||||||
|  | 	_, err = io.Copy(w, resp.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		h.servePlaceholder(w, r) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *ImagesHandler) servePlaceholder(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	placeholderPaths := []string{ | ||||||
|  | 		"./assets/placeholder.jpg", | ||||||
|  | 		"./public/images/placeholder.jpg", | ||||||
|  | 		"./static/placeholder.jpg", | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var placeholderPath string | ||||||
|  | 	for _, path := range placeholderPaths { | ||||||
|  | 		if _, err := os.Stat(path); err == nil { | ||||||
|  | 			placeholderPath = path | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if placeholderPath == "" { | ||||||
|  | 		h.serveSVGPlaceholder(w, r) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	file, err := os.Open(placeholderPath) | ||||||
|  | 	if err != nil { | ||||||
|  | 		h.serveSVGPlaceholder(w, r) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	defer file.Close() | ||||||
|  |  | ||||||
|  | 	ext := strings.ToLower(filepath.Ext(placeholderPath)) | ||||||
|  | 	switch ext { | ||||||
|  | 	case ".jpg", ".jpeg": | ||||||
|  | 		w.Header().Set("Content-Type", "image/jpeg") | ||||||
|  | 	case ".png": | ||||||
|  | 		w.Header().Set("Content-Type", "image/png") | ||||||
|  | 	case ".gif": | ||||||
|  | 		w.Header().Set("Content-Type", "image/gif") | ||||||
|  | 	case ".webp": | ||||||
|  | 		w.Header().Set("Content-Type", "image/webp") | ||||||
|  | 	default: | ||||||
|  | 		w.Header().Set("Content-Type", "image/jpeg") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Cache-Control", "public, max-age=3600") | ||||||
|  |  | ||||||
|  | 	_, err = io.Copy(w, file) | ||||||
|  | 	if err != nil { | ||||||
|  | 		h.serveSVGPlaceholder(w, r) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *ImagesHandler) serveSVGPlaceholder(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	svgPlaceholder := `<svg width="300" height="450" xmlns="http://www.w3.org/2000/svg"> | ||||||
|  | 		<rect width="100%" height="100%" fill="#f0f0f0"/> | ||||||
|  | 		<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" fill="#666"> | ||||||
|  | 			Изображение не найдено | ||||||
|  | 		</text> | ||||||
|  | 	</svg>` | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "image/svg+xml") | ||||||
|  | 	w.Header().Set("Cache-Control", "public, max-age=3600") | ||||||
|  | 	w.Write([]byte(svgPlaceholder)) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *ImagesHandler) isValidSize(size string, validSizes []string) bool { | ||||||
|  | 	for _, validSize := range validSizes { | ||||||
|  | 		if size == validSize { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return false | ||||||
|  | } | ||||||
							
								
								
									
										225
									
								
								pkg/handlers/movie.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								pkg/handlers/movie.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,225 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"net/http" | ||||||
|  | 	"strconv" | ||||||
|  |  | ||||||
|  | 	"github.com/gorilla/mux" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | 	"neomovies-api/pkg/services" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type MovieHandler struct { | ||||||
|  | 	movieService *services.MovieService | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewMovieHandler(movieService *services.MovieService) *MovieHandler { | ||||||
|  | 	return &MovieHandler{ | ||||||
|  | 		movieService: movieService, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *MovieHandler) Search(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	query := r.URL.Query().Get("query") | ||||||
|  | 	if query == "" { | ||||||
|  | 		http.Error(w, "Query parameter is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  | 	region := r.URL.Query().Get("region") | ||||||
|  | 	year := getIntQuery(r, "year", 0) | ||||||
|  |  | ||||||
|  | 	movies, err := h.movieService.Search(query, page, language, region, year) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    movies, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *MovieHandler) GetByID(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	id, err := strconv.Atoi(vars["id"]) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Invalid movie ID", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  |  | ||||||
|  | 	movie, err := h.movieService.GetByID(id, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    movie, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *MovieHandler) Popular(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  | 	region := r.URL.Query().Get("region") | ||||||
|  |  | ||||||
|  | 	movies, err := h.movieService.GetPopular(page, language, region) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    movies, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *MovieHandler) TopRated(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  | 	region := r.URL.Query().Get("region") | ||||||
|  |  | ||||||
|  | 	movies, err := h.movieService.GetTopRated(page, language, region) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    movies, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *MovieHandler) Upcoming(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  | 	region := r.URL.Query().Get("region") | ||||||
|  |  | ||||||
|  | 	movies, err := h.movieService.GetUpcoming(page, language, region) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    movies, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *MovieHandler) NowPlaying(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  | 	region := r.URL.Query().Get("region") | ||||||
|  |  | ||||||
|  | 	movies, err := h.movieService.GetNowPlaying(page, language, region) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    movies, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *MovieHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	id, err := strconv.Atoi(vars["id"]) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Invalid movie ID", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  |  | ||||||
|  | 	movies, err := h.movieService.GetRecommendations(id, page, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    movies, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *MovieHandler) GetSimilar(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	id, err := strconv.Atoi(vars["id"]) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Invalid movie ID", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  |  | ||||||
|  | 	movies, err := h.movieService.GetSimilar(id, page, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    movies, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *MovieHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	id, err := strconv.Atoi(vars["id"]) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Invalid movie ID", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	externalIDs, err := h.movieService.GetExternalIDs(id) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    externalIDs, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func getIntQuery(r *http.Request, key string, defaultValue int) int { | ||||||
|  | 	str := r.URL.Query().Get(key) | ||||||
|  | 	if str == "" { | ||||||
|  | 		return defaultValue | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	value, err := strconv.Atoi(str) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return defaultValue | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return value | ||||||
|  | } | ||||||
							
								
								
									
										343
									
								
								pkg/handlers/players.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										343
									
								
								pkg/handlers/players.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,343 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"log" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 	"strconv" | ||||||
|  | 	"strings" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"github.com/gorilla/mux" | ||||||
|  | 	"neomovies-api/pkg/config" | ||||||
|  | 	"neomovies-api/pkg/players" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type PlayersHandler struct { | ||||||
|  | 	config *config.Config | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewPlayersHandler(cfg *config.Config) *PlayersHandler { | ||||||
|  | 	return &PlayersHandler{ | ||||||
|  | 		config: cfg, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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) | ||||||
|  | 		http.Error(w, "Failed to fetch from Alloha API", http.StatusInternalServerError) | ||||||
|  | 		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 { | ||||||
|  | 			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(`<iframe src="%s" allowfullscreen style="border:none;width:100%%;height:100%%"></iframe>`, iframeCode) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Alloha Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, 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(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, url) | ||||||
|  | 	htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Lumex Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframe) | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "text/html") | ||||||
|  | 	w.Write([]byte(htmlDoc)) | ||||||
|  |  | ||||||
|  | 	log.Printf("Successfully served Lumex player for imdb_id: %s", imdbID) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetRgShowsPlayer handles RgShows streaming requests | ||||||
|  | func (h *PlayersHandler) GetRgShowsPlayer(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	log.Printf("GetRgShowsPlayer called: %s %s", r.Method, r.URL.Path) | ||||||
|  |  | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	tmdbID := vars["tmdb_id"] | ||||||
|  | 	if tmdbID == "" { | ||||||
|  | 		log.Printf("Error: tmdb_id is empty") | ||||||
|  | 		http.Error(w, "tmdb_id path param is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	log.Printf("Processing tmdb_id: %s", tmdbID) | ||||||
|  |  | ||||||
|  | 	pm := players.NewPlayersManager() | ||||||
|  | 	result, err := pm.GetMovieStreamByProvider("rgshows", tmdbID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("Error getting RgShows stream: %v", err) | ||||||
|  | 		http.Error(w, "Failed to get stream", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if !result.Success { | ||||||
|  | 		log.Printf("RgShows stream not found: %s", result.Error) | ||||||
|  | 		http.Error(w, "Stream not found", http.StatusNotFound) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Create iframe with the stream URL | ||||||
|  | 	iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, result.StreamURL) | ||||||
|  | 	htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>RgShows Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframe) | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "text/html") | ||||||
|  | 	w.Write([]byte(htmlDoc)) | ||||||
|  |  | ||||||
|  | 	log.Printf("Successfully served RgShows player for tmdb_id: %s", tmdbID) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetRgShowsTVPlayer handles RgShows TV show streaming requests | ||||||
|  | func (h *PlayersHandler) GetRgShowsTVPlayer(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	log.Printf("GetRgShowsTVPlayer called: %s %s", r.Method, r.URL.Path) | ||||||
|  |  | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	tmdbID := vars["tmdb_id"] | ||||||
|  | 	seasonStr := vars["season"] | ||||||
|  | 	episodeStr := vars["episode"] | ||||||
|  |  | ||||||
|  | 	if tmdbID == "" || seasonStr == "" || episodeStr == "" { | ||||||
|  | 		log.Printf("Error: missing required parameters") | ||||||
|  | 		http.Error(w, "tmdb_id, season, and episode path params are required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	season, err := strconv.Atoi(seasonStr) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("Error parsing season: %v", err) | ||||||
|  | 		http.Error(w, "Invalid season number", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	episode, err := strconv.Atoi(episodeStr) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("Error parsing episode: %v", err) | ||||||
|  | 		http.Error(w, "Invalid episode number", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	log.Printf("Processing tmdb_id: %s, season: %d, episode: %d", tmdbID, season, episode) | ||||||
|  |  | ||||||
|  | 	pm := players.NewPlayersManager() | ||||||
|  | 	result, err := pm.GetTVStreamByProvider("rgshows", tmdbID, season, episode) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("Error getting RgShows TV stream: %v", err) | ||||||
|  | 		http.Error(w, "Failed to get stream", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if !result.Success { | ||||||
|  | 		log.Printf("RgShows TV stream not found: %s", result.Error) | ||||||
|  | 		http.Error(w, "Stream not found", http.StatusNotFound) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Create iframe with the stream URL | ||||||
|  | 	iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, result.StreamURL) | ||||||
|  | 	htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>RgShows TV Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframe) | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "text/html") | ||||||
|  | 	w.Write([]byte(htmlDoc)) | ||||||
|  |  | ||||||
|  | 	log.Printf("Successfully served RgShows TV player for tmdb_id: %s, S%dE%d", tmdbID, season, episode) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetIframeVideoPlayer handles IframeVideo streaming requests | ||||||
|  | func (h *PlayersHandler) GetIframeVideoPlayer(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	log.Printf("GetIframeVideoPlayer called: %s %s", r.Method, r.URL.Path) | ||||||
|  |  | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	kinopoiskID := vars["kinopoisk_id"] | ||||||
|  | 	imdbID := vars["imdb_id"] | ||||||
|  |  | ||||||
|  | 	if kinopoiskID == "" && imdbID == "" { | ||||||
|  | 		log.Printf("Error: both kinopoisk_id and imdb_id are empty") | ||||||
|  | 		http.Error(w, "Either kinopoisk_id or imdb_id path param is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	log.Printf("Processing kinopoisk_id: %s, imdb_id: %s", kinopoiskID, imdbID) | ||||||
|  |  | ||||||
|  | 	pm := players.NewPlayersManager() | ||||||
|  | 	result, err := pm.GetStreamWithKinopoisk(kinopoiskID, imdbID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("Error getting IframeVideo stream: %v", err) | ||||||
|  | 		http.Error(w, "Failed to get stream", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if !result.Success { | ||||||
|  | 		log.Printf("IframeVideo stream not found: %s", result.Error) | ||||||
|  | 		http.Error(w, "Stream not found", http.StatusNotFound) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Create iframe with the stream URL | ||||||
|  | 	iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, result.StreamURL) | ||||||
|  | 	htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>IframeVideo Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframe) | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "text/html") | ||||||
|  | 	w.Write([]byte(htmlDoc)) | ||||||
|  |  | ||||||
|  | 	log.Printf("Successfully served IframeVideo player for kinopoisk_id: %s, imdb_id: %s", kinopoiskID, imdbID) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetStreamAPI returns stream information as JSON API | ||||||
|  | func (h *PlayersHandler) GetStreamAPI(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	log.Printf("GetStreamAPI called: %s %s", r.Method, r.URL.Path) | ||||||
|  |  | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	provider := vars["provider"] | ||||||
|  | 	tmdbID := vars["tmdb_id"] | ||||||
|  |  | ||||||
|  | 	if provider == "" || tmdbID == "" { | ||||||
|  | 		log.Printf("Error: missing required parameters") | ||||||
|  | 		http.Error(w, "provider and tmdb_id path params are required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Check for TV show parameters | ||||||
|  | 	seasonStr := r.URL.Query().Get("season") | ||||||
|  | 	episodeStr := r.URL.Query().Get("episode") | ||||||
|  | 	kinopoiskID := r.URL.Query().Get("kinopoisk_id") | ||||||
|  | 	imdbID := r.URL.Query().Get("imdb_id") | ||||||
|  |  | ||||||
|  | 	log.Printf("Processing provider: %s, tmdb_id: %s", provider, tmdbID) | ||||||
|  |  | ||||||
|  | 	pm := players.NewPlayersManager() | ||||||
|  | 	var result *players.StreamResult | ||||||
|  | 	var err error | ||||||
|  |  | ||||||
|  | 	switch provider { | ||||||
|  | 	case "iframevideo": | ||||||
|  | 		if kinopoiskID == "" && imdbID == "" { | ||||||
|  | 			http.Error(w, "kinopoisk_id or imdb_id query param is required for IframeVideo", http.StatusBadRequest) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		result, err = pm.GetStreamWithKinopoisk(kinopoiskID, imdbID) | ||||||
|  | 	case "rgshows": | ||||||
|  | 		if seasonStr != "" && episodeStr != "" { | ||||||
|  | 			season, err1 := strconv.Atoi(seasonStr) | ||||||
|  | 			episode, err2 := strconv.Atoi(episodeStr) | ||||||
|  | 			if err1 != nil || err2 != nil { | ||||||
|  | 				http.Error(w, "Invalid season or episode number", http.StatusBadRequest) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  | 			result, err = pm.GetTVStreamByProvider("rgshows", tmdbID, season, episode) | ||||||
|  | 		} else { | ||||||
|  | 			result, err = pm.GetMovieStreamByProvider("rgshows", tmdbID) | ||||||
|  | 		} | ||||||
|  | 	default: | ||||||
|  | 		http.Error(w, "Unsupported provider", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err != nil { | ||||||
|  | 		log.Printf("Error getting stream from %s: %v", provider, err) | ||||||
|  | 		result = &players.StreamResult{ | ||||||
|  | 			Success:  false, | ||||||
|  | 			Provider: provider, | ||||||
|  | 			Error:    err.Error(), | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(result) | ||||||
|  |  | ||||||
|  | 	log.Printf("Successfully served stream API for provider: %s, tmdb_id: %s", provider, tmdbID) | ||||||
|  | } | ||||||
							
								
								
									
										151
									
								
								pkg/handlers/reactions.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								pkg/handlers/reactions.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,151 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"net/http" | ||||||
|  |  | ||||||
|  | 	"github.com/gorilla/mux" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/middleware" | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | 	"neomovies-api/pkg/services" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type ReactionsHandler struct { | ||||||
|  | 	reactionsService *services.ReactionsService | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewReactionsHandler(reactionsService *services.ReactionsService) *ReactionsHandler { | ||||||
|  | 	return &ReactionsHandler{reactionsService: reactionsService} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *ReactionsHandler) GetReactionCounts(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	mediaType := vars["mediaType"] | ||||||
|  | 	mediaID := vars["mediaId"] | ||||||
|  |  | ||||||
|  | 	if mediaType == "" || mediaID == "" { | ||||||
|  | 		http.Error(w, "Media type and ID are required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	counts, err := h.reactionsService.GetReactionCounts(mediaType, mediaID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(counts) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *ReactionsHandler) GetMyReaction(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	mediaType := vars["mediaType"] | ||||||
|  | 	mediaID := vars["mediaId"] | ||||||
|  |  | ||||||
|  | 	if mediaType == "" || mediaID == "" { | ||||||
|  | 		http.Error(w, "Media type and ID are required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	reactionType, err := h.reactionsService.GetMyReaction(userID, mediaType, mediaID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	if reactionType == "" { | ||||||
|  | 		json.NewEncoder(w).Encode(map[string]interface{}{}) | ||||||
|  | 	} else { | ||||||
|  | 		json.NewEncoder(w).Encode(map[string]string{"type": reactionType}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *ReactionsHandler) SetReaction(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	mediaType := vars["mediaType"] | ||||||
|  | 	mediaID := vars["mediaId"] | ||||||
|  |  | ||||||
|  | 	if mediaType == "" || mediaID == "" { | ||||||
|  | 		http.Error(w, "Media type and ID are required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var request struct { | ||||||
|  | 		Type string `json:"type"` | ||||||
|  | 	} | ||||||
|  | 	if err := json.NewDecoder(r.Body).Decode(&request); err != nil { | ||||||
|  | 		http.Error(w, "Invalid request body", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if request.Type == "" { | ||||||
|  | 		http.Error(w, "Reaction type is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err := h.reactionsService.SetReaction(userID, mediaType, mediaID, request.Type); err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction set successfully"}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *ReactionsHandler) RemoveReaction(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	mediaType := vars["mediaType"] | ||||||
|  | 	mediaID := vars["mediaId"] | ||||||
|  |  | ||||||
|  | 	if mediaType == "" || mediaID == "" { | ||||||
|  | 		http.Error(w, "Media type and ID are required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err := h.reactionsService.RemoveReaction(userID, mediaType, mediaID); err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Reaction removed successfully"}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *ReactionsHandler) GetMyReactions(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	userID, ok := middleware.GetUserIDFromContext(r.Context()) | ||||||
|  | 	if !ok { | ||||||
|  | 		http.Error(w, "User ID not found in context", http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	limit := getIntQuery(r, "limit", 50) | ||||||
|  |  | ||||||
|  | 	reactions, err := h.reactionsService.GetUserReactions(userID, limit) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: reactions}) | ||||||
|  | } | ||||||
							
								
								
									
										45
									
								
								pkg/handlers/search.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								pkg/handlers/search.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"net/http" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | 	"neomovies-api/pkg/services" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type SearchHandler struct { | ||||||
|  | 	tmdbService *services.TMDBService | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewSearchHandler(tmdbService *services.TMDBService) *SearchHandler { | ||||||
|  | 	return &SearchHandler{ | ||||||
|  | 		tmdbService: tmdbService, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *SearchHandler) MultiSearch(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	query := r.URL.Query().Get("query") | ||||||
|  | 	if query == "" { | ||||||
|  | 		http.Error(w, "Query parameter is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  | 	if language == "" { | ||||||
|  | 		language = "ru-RU" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	results, err := h.tmdbService.SearchMulti(query, page, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    results, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
							
								
								
									
										367
									
								
								pkg/handlers/torrents.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										367
									
								
								pkg/handlers/torrents.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,367 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"net/http" | ||||||
|  | 	"strconv" | ||||||
|  | 	"strings" | ||||||
|  |  | ||||||
|  | 	"github.com/gorilla/mux" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | 	"neomovies-api/pkg/services" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type TorrentsHandler struct { | ||||||
|  | 	torrentService *services.TorrentService | ||||||
|  | 	tmdbService    *services.TMDBService | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewTorrentsHandler(torrentService *services.TorrentService, tmdbService *services.TMDBService) *TorrentsHandler { | ||||||
|  | 	return &TorrentsHandler{ | ||||||
|  | 		torrentService: torrentService, | ||||||
|  | 		tmdbService:    tmdbService, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchTorrents - поиск торрентов по IMDB ID | ||||||
|  | func (h *TorrentsHandler) SearchTorrents(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	imdbID := vars["imdbId"] | ||||||
|  |  | ||||||
|  | 	if imdbID == "" { | ||||||
|  | 		http.Error(w, "IMDB ID is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Параметры запроса | ||||||
|  | 	mediaType := r.URL.Query().Get("type") | ||||||
|  | 	if mediaType == "" { | ||||||
|  | 		mediaType = "movie" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Создаем опции поиска | ||||||
|  | 	options := &models.TorrentSearchOptions{ | ||||||
|  | 		ContentType: mediaType, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Качество | ||||||
|  | 	if quality := r.URL.Query().Get("quality"); quality != "" { | ||||||
|  | 		options.Quality = strings.Split(quality, ",") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Минимальное и максимальное качество | ||||||
|  | 	options.MinQuality = r.URL.Query().Get("minQuality") | ||||||
|  | 	options.MaxQuality = r.URL.Query().Get("maxQuality") | ||||||
|  |  | ||||||
|  | 	// Исключаемые качества | ||||||
|  | 	if excludeQualities := r.URL.Query().Get("excludeQualities"); excludeQualities != "" { | ||||||
|  | 		options.ExcludeQualities = strings.Split(excludeQualities, ",") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// HDR | ||||||
|  | 	if hdr := r.URL.Query().Get("hdr"); hdr != "" { | ||||||
|  | 		if hdrBool, err := strconv.ParseBool(hdr); err == nil { | ||||||
|  | 			options.HDR = &hdrBool | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// HEVC | ||||||
|  | 	if hevc := r.URL.Query().Get("hevc"); hevc != "" { | ||||||
|  | 		if hevcBool, err := strconv.ParseBool(hevc); err == nil { | ||||||
|  | 			options.HEVC = &hevcBool | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Сортировка | ||||||
|  | 	options.SortBy = r.URL.Query().Get("sortBy") | ||||||
|  | 	if options.SortBy == "" { | ||||||
|  | 		options.SortBy = "seeders" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	options.SortOrder = r.URL.Query().Get("sortOrder") | ||||||
|  | 	if options.SortOrder == "" { | ||||||
|  | 		options.SortOrder = "desc" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Группировка | ||||||
|  | 	if groupByQuality := r.URL.Query().Get("groupByQuality"); groupByQuality == "true" { | ||||||
|  | 		options.GroupByQuality = true | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if groupBySeason := r.URL.Query().Get("groupBySeason"); groupBySeason == "true" { | ||||||
|  | 		options.GroupBySeason = true | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Сезон для сериалов | ||||||
|  | 	if season := r.URL.Query().Get("season"); season != "" { | ||||||
|  | 		if seasonInt, err := strconv.Atoi(season); err == nil { | ||||||
|  | 			options.Season = &seasonInt | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Поиск торрентов | ||||||
|  | 	results, err := h.torrentService.SearchTorrentsByIMDbID(h.tmdbService, imdbID, mediaType, options) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Формируем ответ с группировкой если необходимо | ||||||
|  | 	response := map[string]interface{}{ | ||||||
|  | 		"imdbId": imdbID, | ||||||
|  | 		"type":   mediaType, | ||||||
|  | 		"total":  results.Total, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if options.Season != nil { | ||||||
|  | 		response["season"] = *options.Season | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Применяем группировку если запрошена | ||||||
|  | 	if options.GroupByQuality && options.GroupBySeason { | ||||||
|  | 		// Группируем сначала по сезонам, затем по качеству внутри каждого сезона | ||||||
|  | 		seasonGroups := h.torrentService.GroupBySeason(results.Results) | ||||||
|  | 		finalGroups := make(map[string]map[string][]models.TorrentResult) | ||||||
|  |  | ||||||
|  | 		for season, torrents := range seasonGroups { | ||||||
|  | 			qualityGroups := h.torrentService.GroupByQuality(torrents) | ||||||
|  | 			finalGroups[season] = qualityGroups | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		response["grouped"] = true | ||||||
|  | 		response["groups"] = finalGroups | ||||||
|  | 	} else if options.GroupByQuality { | ||||||
|  | 		groups := h.torrentService.GroupByQuality(results.Results) | ||||||
|  | 		response["grouped"] = true | ||||||
|  | 		response["groups"] = groups | ||||||
|  | 	} else if options.GroupBySeason { | ||||||
|  | 		groups := h.torrentService.GroupBySeason(results.Results) | ||||||
|  | 		response["grouped"] = true | ||||||
|  | 		response["groups"] = groups | ||||||
|  | 	} else { | ||||||
|  | 		response["grouped"] = false | ||||||
|  | 		response["results"] = results.Results | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if len(results.Results) == 0 { | ||||||
|  | 		response["error"] = "No torrents found for this IMDB ID" | ||||||
|  | 		w.Header().Set("Content-Type", "application/json") | ||||||
|  | 		w.WriteHeader(http.StatusNotFound) | ||||||
|  | 		json.NewEncoder(w).Encode(response) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    response, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchMovies - поиск фильмов по названию | ||||||
|  | func (h *TorrentsHandler) SearchMovies(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	title := r.URL.Query().Get("title") | ||||||
|  | 	originalTitle := r.URL.Query().Get("originalTitle") | ||||||
|  | 	year := r.URL.Query().Get("year") | ||||||
|  |  | ||||||
|  | 	if title == "" && originalTitle == "" { | ||||||
|  | 		http.Error(w, "Title or original title is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	results, err := h.torrentService.SearchMovies(title, originalTitle, year) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response := map[string]interface{}{ | ||||||
|  | 		"title":         title, | ||||||
|  | 		"originalTitle": originalTitle, | ||||||
|  | 		"year":          year, | ||||||
|  | 		"type":          "movie", | ||||||
|  | 		"total":         results.Total, | ||||||
|  | 		"results":       results.Results, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    response, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchSeries - поиск сериалов по названию с поддержкой сезонов | ||||||
|  | func (h *TorrentsHandler) SearchSeries(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	title := r.URL.Query().Get("title") | ||||||
|  | 	originalTitle := r.URL.Query().Get("originalTitle") | ||||||
|  | 	year := r.URL.Query().Get("year") | ||||||
|  |  | ||||||
|  | 	if title == "" && originalTitle == "" { | ||||||
|  | 		http.Error(w, "Title or original title is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var season *int | ||||||
|  | 	if seasonStr := r.URL.Query().Get("season"); seasonStr != "" { | ||||||
|  | 		if seasonInt, err := strconv.Atoi(seasonStr); err == nil { | ||||||
|  | 			season = &seasonInt | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	results, err := h.torrentService.SearchSeries(title, originalTitle, year, season) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response := map[string]interface{}{ | ||||||
|  | 		"title":         title, | ||||||
|  | 		"originalTitle": originalTitle, | ||||||
|  | 		"year":          year, | ||||||
|  | 		"type":          "series", | ||||||
|  | 		"total":         results.Total, | ||||||
|  | 		"results":       results.Results, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if season != nil { | ||||||
|  | 		response["season"] = *season | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    response, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchAnime - поиск аниме по названию | ||||||
|  | func (h *TorrentsHandler) SearchAnime(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	title := r.URL.Query().Get("title") | ||||||
|  | 	originalTitle := r.URL.Query().Get("originalTitle") | ||||||
|  | 	year := r.URL.Query().Get("year") | ||||||
|  |  | ||||||
|  | 	if title == "" && originalTitle == "" { | ||||||
|  | 		http.Error(w, "Title or original title is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	results, err := h.torrentService.SearchAnime(title, originalTitle, year) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response := map[string]interface{}{ | ||||||
|  | 		"title":         title, | ||||||
|  | 		"originalTitle": originalTitle, | ||||||
|  | 		"year":          year, | ||||||
|  | 		"type":          "anime", | ||||||
|  | 		"total":         results.Total, | ||||||
|  | 		"results":       results.Results, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    response, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetAvailableSeasons - получение доступных сезонов для сериала | ||||||
|  | func (h *TorrentsHandler) GetAvailableSeasons(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	title := r.URL.Query().Get("title") | ||||||
|  | 	originalTitle := r.URL.Query().Get("originalTitle") | ||||||
|  | 	year := r.URL.Query().Get("year") | ||||||
|  |  | ||||||
|  | 	if title == "" && originalTitle == "" { | ||||||
|  | 		http.Error(w, "Title or original title is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	seasons, err := h.torrentService.GetAvailableSeasons(title, originalTitle, year) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response := map[string]interface{}{ | ||||||
|  | 		"title":         title, | ||||||
|  | 		"originalTitle": originalTitle, | ||||||
|  | 		"year":          year, | ||||||
|  | 		"seasons":       seasons, | ||||||
|  | 		"total":         len(seasons), | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    response, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchByQuery - универсальный поиск торрентов | ||||||
|  | func (h *TorrentsHandler) SearchByQuery(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	query := r.URL.Query().Get("query") | ||||||
|  | 	if query == "" { | ||||||
|  | 		http.Error(w, "Query is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	contentType := r.URL.Query().Get("type") | ||||||
|  | 	if contentType == "" { | ||||||
|  | 		contentType = "movie" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	year := r.URL.Query().Get("year") | ||||||
|  |  | ||||||
|  | 	// Формируем параметры поиска | ||||||
|  | 	params := map[string]string{ | ||||||
|  | 		"query": query, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if year != "" { | ||||||
|  | 		params["year"] = year | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Устанавливаем тип контента и категорию | ||||||
|  | 	switch contentType { | ||||||
|  | 	case "movie": | ||||||
|  | 		params["is_serial"] = "1" | ||||||
|  | 		params["category"] = "2000" | ||||||
|  | 	case "series", "tv": | ||||||
|  | 		params["is_serial"] = "2" | ||||||
|  | 		params["category"] = "5000" | ||||||
|  | 	case "anime": | ||||||
|  | 		params["is_serial"] = "5" | ||||||
|  | 		params["category"] = "5070" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	results, err := h.torrentService.SearchTorrents(params) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Применяем фильтрацию по типу контента | ||||||
|  | 	options := &models.TorrentSearchOptions{ | ||||||
|  | 		ContentType: contentType, | ||||||
|  | 	} | ||||||
|  | 	results.Results = h.torrentService.FilterByContentType(results.Results, options.ContentType) | ||||||
|  | 	results.Total = len(results.Results) | ||||||
|  |  | ||||||
|  | 	response := map[string]interface{}{ | ||||||
|  | 		"query":   query, | ||||||
|  | 		"type":    contentType, | ||||||
|  | 		"year":    year, | ||||||
|  | 		"total":   results.Total, | ||||||
|  | 		"results": results.Results, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    response, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
							
								
								
									
										206
									
								
								pkg/handlers/tv.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								pkg/handlers/tv.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,206 @@ | |||||||
|  | package handlers | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"net/http" | ||||||
|  | 	"strconv" | ||||||
|  |  | ||||||
|  | 	"github.com/gorilla/mux" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | 	"neomovies-api/pkg/services" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type TVHandler struct { | ||||||
|  | 	tvService *services.TVService | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewTVHandler(tvService *services.TVService) *TVHandler { | ||||||
|  | 	return &TVHandler{ | ||||||
|  | 		tvService: tvService, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *TVHandler) Search(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	query := r.URL.Query().Get("query") | ||||||
|  | 	if query == "" { | ||||||
|  | 		http.Error(w, "Query parameter is required", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  | 	year := getIntQuery(r, "first_air_date_year", 0) | ||||||
|  |  | ||||||
|  | 	tvShows, err := h.tvService.Search(query, page, language, year) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    tvShows, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *TVHandler) GetByID(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	id, err := strconv.Atoi(vars["id"]) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Invalid TV show ID", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  |  | ||||||
|  | 	tvShow, err := h.tvService.GetByID(id, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    tvShow, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *TVHandler) Popular(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  |  | ||||||
|  | 	tvShows, err := h.tvService.GetPopular(page, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    tvShows, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *TVHandler) TopRated(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  |  | ||||||
|  | 	tvShows, err := h.tvService.GetTopRated(page, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    tvShows, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *TVHandler) OnTheAir(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  |  | ||||||
|  | 	tvShows, err := h.tvService.GetOnTheAir(page, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    tvShows, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *TVHandler) AiringToday(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  |  | ||||||
|  | 	tvShows, err := h.tvService.GetAiringToday(page, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    tvShows, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *TVHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	id, err := strconv.Atoi(vars["id"]) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Invalid TV show ID", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  |  | ||||||
|  | 	tvShows, err := h.tvService.GetRecommendations(id, page, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    tvShows, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *TVHandler) GetSimilar(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	id, err := strconv.Atoi(vars["id"]) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Invalid TV show ID", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	page := getIntQuery(r, "page", 1) | ||||||
|  | 	language := r.URL.Query().Get("language") | ||||||
|  |  | ||||||
|  | 	tvShows, err := h.tvService.GetSimilar(id, page, language) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    tvShows, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (h *TVHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 	vars := mux.Vars(r) | ||||||
|  | 	id, err := strconv.Atoi(vars["id"]) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, "Invalid TV show ID", http.StatusBadRequest) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	externalIDs, err := h.tvService.GetExternalIDs(id) | ||||||
|  | 	if err != nil { | ||||||
|  | 		http.Error(w, err.Error(), http.StatusInternalServerError) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	w.Header().Set("Content-Type", "application/json") | ||||||
|  | 	json.NewEncoder(w).Encode(models.APIResponse{ | ||||||
|  | 		Success: true, | ||||||
|  | 		Data:    externalIDs, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
							
								
								
									
										63
									
								
								pkg/middleware/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								pkg/middleware/auth.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | |||||||
|  | package middleware | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"net/http" | ||||||
|  | 	"strings" | ||||||
|  |  | ||||||
|  | 	"github.com/golang-jwt/jwt/v5" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type contextKey string | ||||||
|  |  | ||||||
|  | const UserIDKey contextKey = "userID" | ||||||
|  |  | ||||||
|  | func JWTAuth(secret string) func(http.Handler) http.Handler { | ||||||
|  | 	return func(next http.Handler) http.Handler { | ||||||
|  | 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 			authHeader := r.Header.Get("Authorization") | ||||||
|  | 			if authHeader == "" { | ||||||
|  | 				http.Error(w, "Authorization header required", http.StatusUnauthorized) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			tokenString := strings.TrimPrefix(authHeader, "Bearer ") | ||||||
|  | 			if tokenString == authHeader { | ||||||
|  | 				http.Error(w, "Bearer token required", http.StatusUnauthorized) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { | ||||||
|  | 				if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | ||||||
|  | 					return nil, jwt.ErrSignatureInvalid | ||||||
|  | 				} | ||||||
|  | 				return []byte(secret), nil | ||||||
|  | 			}) | ||||||
|  |  | ||||||
|  | 			if err != nil || !token.Valid { | ||||||
|  | 				http.Error(w, "Invalid token", http.StatusUnauthorized) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			claims, ok := token.Claims.(jwt.MapClaims) | ||||||
|  | 			if !ok { | ||||||
|  | 				http.Error(w, "Invalid token claims", http.StatusUnauthorized) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			userID, ok := claims["user_id"].(string) | ||||||
|  | 			if !ok { | ||||||
|  | 				http.Error(w, "Invalid user ID in token", http.StatusUnauthorized) | ||||||
|  | 				return | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			ctx := context.WithValue(r.Context(), UserIDKey, userID) | ||||||
|  | 			next.ServeHTTP(w, r.WithContext(ctx)) | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func GetUserIDFromContext(ctx context.Context) (string, bool) { | ||||||
|  | 	userID, ok := ctx.Value(UserIDKey).(string) | ||||||
|  | 	return userID, ok | ||||||
|  | } | ||||||
							
								
								
									
										24
									
								
								pkg/models/favorite.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								pkg/models/favorite.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | package models | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"go.mongodb.org/mongo-driver/bson/primitive" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type Favorite struct { | ||||||
|  | 	ID         primitive.ObjectID `json:"id" bson:"_id,omitempty"` | ||||||
|  | 	UserID     string             `json:"userId" bson:"userId"` | ||||||
|  | 	MediaID    string             `json:"mediaId" bson:"mediaId"` | ||||||
|  | 	MediaType  string             `json:"mediaType" bson:"mediaType"` // "movie" or "tv" | ||||||
|  | 	Title      string             `json:"title" bson:"title"` | ||||||
|  | 	PosterPath string             `json:"posterPath" bson:"posterPath"` | ||||||
|  | 	CreatedAt  time.Time          `json:"createdAt" bson:"createdAt"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type FavoriteRequest struct { | ||||||
|  | 	MediaID    string `json:"mediaId" validate:"required"` | ||||||
|  | 	MediaType  string `json:"mediaType" validate:"required,oneof=movie tv"` | ||||||
|  | 	Title      string `json:"title" validate:"required"` | ||||||
|  | 	PosterPath string `json:"posterPath"` | ||||||
|  | } | ||||||
							
								
								
									
										334
									
								
								pkg/models/movie.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										334
									
								
								pkg/models/movie.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,334 @@ | |||||||
|  | package models | ||||||
|  |  | ||||||
|  | // MediaInfo represents media information structure used by handlers and services | ||||||
|  | type MediaInfo struct { | ||||||
|  | 	ID            string  `json:"id"` | ||||||
|  | 	Title         string  `json:"title"` | ||||||
|  | 	OriginalTitle string  `json:"original_title,omitempty"` | ||||||
|  | 	Overview      string  `json:"overview"` | ||||||
|  | 	PosterPath    string  `json:"poster_path"` | ||||||
|  | 	BackdropPath  string  `json:"backdrop_path"` | ||||||
|  | 	ReleaseDate   string  `json:"release_date,omitempty"` | ||||||
|  | 	FirstAirDate  string  `json:"first_air_date,omitempty"` | ||||||
|  | 	VoteAverage   float64 `json:"vote_average"` | ||||||
|  | 	VoteCount     int     `json:"vote_count"` | ||||||
|  | 	MediaType     string  `json:"media_type"` | ||||||
|  | 	Popularity    float64 `json:"popularity"` | ||||||
|  | 	GenreIDs      []int   `json:"genre_ids"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type Movie struct { | ||||||
|  | 	ID                  int                 `json:"id"` | ||||||
|  | 	Title               string              `json:"title"` | ||||||
|  | 	OriginalTitle       string              `json:"original_title"` | ||||||
|  | 	Overview            string              `json:"overview"` | ||||||
|  | 	PosterPath          string              `json:"poster_path"` | ||||||
|  | 	BackdropPath        string              `json:"backdrop_path"` | ||||||
|  | 	ReleaseDate         string              `json:"release_date"` | ||||||
|  | 	GenreIDs            []int               `json:"genre_ids"` | ||||||
|  | 	Genres              []Genre             `json:"genres"` | ||||||
|  | 	VoteAverage         float64             `json:"vote_average"` | ||||||
|  | 	VoteCount           int                 `json:"vote_count"` | ||||||
|  | 	Popularity          float64             `json:"popularity"` | ||||||
|  | 	Adult               bool                `json:"adult"` | ||||||
|  | 	Video               bool                `json:"video"` | ||||||
|  | 	OriginalLanguage    string              `json:"original_language"` | ||||||
|  | 	Runtime             int                 `json:"runtime,omitempty"` | ||||||
|  | 	Budget              int64               `json:"budget,omitempty"` | ||||||
|  | 	Revenue             int64               `json:"revenue,omitempty"` | ||||||
|  | 	Status              string              `json:"status,omitempty"` | ||||||
|  | 	Tagline             string              `json:"tagline,omitempty"` | ||||||
|  | 	Homepage            string              `json:"homepage,omitempty"` | ||||||
|  | 	IMDbID              string              `json:"imdb_id,omitempty"` | ||||||
|  | 	BelongsToCollection *Collection         `json:"belongs_to_collection,omitempty"` | ||||||
|  | 	ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"` | ||||||
|  | 	ProductionCountries []ProductionCountry `json:"production_countries,omitempty"` | ||||||
|  | 	SpokenLanguages     []SpokenLanguage    `json:"spoken_languages,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type TVShow struct { | ||||||
|  | 	ID                  int                 `json:"id"` | ||||||
|  | 	Name                string              `json:"name"` | ||||||
|  | 	OriginalName        string              `json:"original_name"` | ||||||
|  | 	Overview            string              `json:"overview"` | ||||||
|  | 	PosterPath          string              `json:"poster_path"` | ||||||
|  | 	BackdropPath        string              `json:"backdrop_path"` | ||||||
|  | 	FirstAirDate        string              `json:"first_air_date"` | ||||||
|  | 	LastAirDate         string              `json:"last_air_date"` | ||||||
|  | 	GenreIDs            []int               `json:"genre_ids"` | ||||||
|  | 	Genres              []Genre             `json:"genres"` | ||||||
|  | 	VoteAverage         float64             `json:"vote_average"` | ||||||
|  | 	VoteCount           int                 `json:"vote_count"` | ||||||
|  | 	Popularity          float64             `json:"popularity"` | ||||||
|  | 	OriginalLanguage    string              `json:"original_language"` | ||||||
|  | 	OriginCountry       []string            `json:"origin_country"` | ||||||
|  | 	NumberOfEpisodes    int                 `json:"number_of_episodes,omitempty"` | ||||||
|  | 	NumberOfSeasons     int                 `json:"number_of_seasons,omitempty"` | ||||||
|  | 	Status              string              `json:"status,omitempty"` | ||||||
|  | 	Type                string              `json:"type,omitempty"` | ||||||
|  | 	Homepage            string              `json:"homepage,omitempty"` | ||||||
|  | 	InProduction        bool                `json:"in_production,omitempty"` | ||||||
|  | 	Languages           []string            `json:"languages,omitempty"` | ||||||
|  | 	Networks            []Network           `json:"networks,omitempty"` | ||||||
|  | 	ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"` | ||||||
|  | 	ProductionCountries []ProductionCountry `json:"production_countries,omitempty"` | ||||||
|  | 	SpokenLanguages     []SpokenLanguage    `json:"spoken_languages,omitempty"` | ||||||
|  | 	CreatedBy           []Creator           `json:"created_by,omitempty"` | ||||||
|  | 	EpisodeRunTime      []int               `json:"episode_run_time,omitempty"` | ||||||
|  | 	Seasons             []Season            `json:"seasons,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // MultiSearchResult для мультипоиска | ||||||
|  | type MultiSearchResult struct { | ||||||
|  | 	ID               int      `json:"id"` | ||||||
|  | 	MediaType        string   `json:"media_type"`      // "movie" или "tv" | ||||||
|  | 	Title            string   `json:"title,omitempty"` // для фильмов | ||||||
|  | 	Name             string   `json:"name,omitempty"`  // для сериалов | ||||||
|  | 	OriginalTitle    string   `json:"original_title,omitempty"` | ||||||
|  | 	OriginalName     string   `json:"original_name,omitempty"` | ||||||
|  | 	Overview         string   `json:"overview"` | ||||||
|  | 	PosterPath       string   `json:"poster_path"` | ||||||
|  | 	BackdropPath     string   `json:"backdrop_path"` | ||||||
|  | 	ReleaseDate      string   `json:"release_date,omitempty"`   // для фильмов | ||||||
|  | 	FirstAirDate     string   `json:"first_air_date,omitempty"` // для сериалов | ||||||
|  | 	GenreIDs         []int    `json:"genre_ids"` | ||||||
|  | 	VoteAverage      float64  `json:"vote_average"` | ||||||
|  | 	VoteCount        int      `json:"vote_count"` | ||||||
|  | 	Popularity       float64  `json:"popularity"` | ||||||
|  | 	Adult            bool     `json:"adult"` | ||||||
|  | 	OriginalLanguage string   `json:"original_language"` | ||||||
|  | 	OriginCountry    []string `json:"origin_country,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type MultiSearchResponse struct { | ||||||
|  | 	Page         int                 `json:"page"` | ||||||
|  | 	Results      []MultiSearchResult `json:"results"` | ||||||
|  | 	TotalPages   int                 `json:"total_pages"` | ||||||
|  | 	TotalResults int                 `json:"total_results"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type Genre struct { | ||||||
|  | 	ID   int    `json:"id"` | ||||||
|  | 	Name string `json:"name"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type GenresResponse struct { | ||||||
|  | 	Genres []Genre `json:"genres"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type ExternalIDs struct { | ||||||
|  | 	ID          int    `json:"id"` | ||||||
|  | 	IMDbID      string `json:"imdb_id"` | ||||||
|  | 	TVDBID      int    `json:"tvdb_id,omitempty"` | ||||||
|  | 	WikidataID  string `json:"wikidata_id"` | ||||||
|  | 	FacebookID  string `json:"facebook_id"` | ||||||
|  | 	InstagramID string `json:"instagram_id"` | ||||||
|  | 	TwitterID   string `json:"twitter_id"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type Collection struct { | ||||||
|  | 	ID           int    `json:"id"` | ||||||
|  | 	Name         string `json:"name"` | ||||||
|  | 	PosterPath   string `json:"poster_path"` | ||||||
|  | 	BackdropPath string `json:"backdrop_path"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type ProductionCompany struct { | ||||||
|  | 	ID            int    `json:"id"` | ||||||
|  | 	LogoPath      string `json:"logo_path"` | ||||||
|  | 	Name          string `json:"name"` | ||||||
|  | 	OriginCountry string `json:"origin_country"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type ProductionCountry struct { | ||||||
|  | 	ISO31661 string `json:"iso_3166_1"` | ||||||
|  | 	Name     string `json:"name"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type SpokenLanguage struct { | ||||||
|  | 	EnglishName string `json:"english_name"` | ||||||
|  | 	ISO6391     string `json:"iso_639_1"` | ||||||
|  | 	Name        string `json:"name"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type Network struct { | ||||||
|  | 	ID            int    `json:"id"` | ||||||
|  | 	LogoPath      string `json:"logo_path"` | ||||||
|  | 	Name          string `json:"name"` | ||||||
|  | 	OriginCountry string `json:"origin_country"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type Creator struct { | ||||||
|  | 	ID          int    `json:"id"` | ||||||
|  | 	CreditID    string `json:"credit_id"` | ||||||
|  | 	Name        string `json:"name"` | ||||||
|  | 	Gender      int    `json:"gender"` | ||||||
|  | 	ProfilePath string `json:"profile_path"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type Season struct { | ||||||
|  | 	AirDate      string `json:"air_date"` | ||||||
|  | 	EpisodeCount int    `json:"episode_count"` | ||||||
|  | 	ID           int    `json:"id"` | ||||||
|  | 	Name         string `json:"name"` | ||||||
|  | 	Overview     string `json:"overview"` | ||||||
|  | 	PosterPath   string `json:"poster_path"` | ||||||
|  | 	SeasonNumber int    `json:"season_number"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type SeasonDetails struct { | ||||||
|  | 	AirDate      string    `json:"air_date"` | ||||||
|  | 	Episodes     []Episode `json:"episodes"` | ||||||
|  | 	Name         string    `json:"name"` | ||||||
|  | 	Overview     string    `json:"overview"` | ||||||
|  | 	ID           int       `json:"id"` | ||||||
|  | 	PosterPath   string    `json:"poster_path"` | ||||||
|  | 	SeasonNumber int       `json:"season_number"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type Episode struct { | ||||||
|  | 	AirDate        string  `json:"air_date"` | ||||||
|  | 	EpisodeNumber  int     `json:"episode_number"` | ||||||
|  | 	ID             int     `json:"id"` | ||||||
|  | 	Name           string  `json:"name"` | ||||||
|  | 	Overview       string  `json:"overview"` | ||||||
|  | 	ProductionCode string  `json:"production_code"` | ||||||
|  | 	Runtime        int     `json:"runtime"` | ||||||
|  | 	SeasonNumber   int     `json:"season_number"` | ||||||
|  | 	ShowID         int     `json:"show_id"` | ||||||
|  | 	StillPath      string  `json:"still_path"` | ||||||
|  | 	VoteAverage    float64 `json:"vote_average"` | ||||||
|  | 	VoteCount      int     `json:"vote_count"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type TMDBResponse struct { | ||||||
|  | 	Page         int     `json:"page"` | ||||||
|  | 	Results      []Movie `json:"results"` | ||||||
|  | 	TotalPages   int     `json:"total_pages"` | ||||||
|  | 	TotalResults int     `json:"total_results"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type TMDBTVResponse struct { | ||||||
|  | 	Page         int      `json:"page"` | ||||||
|  | 	Results      []TVShow `json:"results"` | ||||||
|  | 	TotalPages   int      `json:"total_pages"` | ||||||
|  | 	TotalResults int      `json:"total_results"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type SearchParams struct { | ||||||
|  | 	Query              string `json:"query"` | ||||||
|  | 	Page               int    `json:"page"` | ||||||
|  | 	Language           string `json:"language"` | ||||||
|  | 	Region             string `json:"region"` | ||||||
|  | 	Year               int    `json:"year"` | ||||||
|  | 	PrimaryReleaseYear int    `json:"primary_release_year"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type APIResponse struct { | ||||||
|  | 	Success bool        `json:"success"` | ||||||
|  | 	Data    interface{} `json:"data,omitempty"` | ||||||
|  | 	Error   string      `json:"error,omitempty"` | ||||||
|  | 	Message string      `json:"message,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Модели для торрентов | ||||||
|  | type TorrentResult struct { | ||||||
|  | 	Title       string   `json:"title"` | ||||||
|  | 	Tracker     string   `json:"tracker"` | ||||||
|  | 	Size        string   `json:"size"` | ||||||
|  | 	Seeders     int      `json:"seeders"` | ||||||
|  | 	Peers       int      `json:"peers"` | ||||||
|  | 	Leechers    int      `json:"leechers"` | ||||||
|  | 	Quality     string   `json:"quality"` | ||||||
|  | 	Voice       []string `json:"voice,omitempty"` | ||||||
|  | 	Types       []string `json:"types,omitempty"` | ||||||
|  | 	Seasons     []int    `json:"seasons,omitempty"` | ||||||
|  | 	Category    string   `json:"category"` | ||||||
|  | 	MagnetLink  string   `json:"magnet"` | ||||||
|  | 	TorrentLink string   `json:"torrent_link,omitempty"` | ||||||
|  | 	Details     string   `json:"details,omitempty"` | ||||||
|  | 	PublishDate string   `json:"publish_date"` | ||||||
|  | 	AddedDate   string   `json:"added_date,omitempty"` | ||||||
|  | 	Source      string   `json:"source"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type TorrentSearchResponse struct { | ||||||
|  | 	Query   string          `json:"query"` | ||||||
|  | 	Results []TorrentResult `json:"results"` | ||||||
|  | 	Total   int             `json:"total"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // RedAPI специфичные структуры | ||||||
|  | type RedAPIResponse struct { | ||||||
|  | 	Results []RedAPITorrent `json:"Results"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type RedAPITorrent struct { | ||||||
|  | 	Title        string             `json:"Title"` | ||||||
|  | 	Tracker      string             `json:"Tracker"` | ||||||
|  | 	Size         interface{}        `json:"Size"` // Может быть string или number | ||||||
|  | 	Seeders      int                `json:"Seeders"` | ||||||
|  | 	Peers        int                `json:"Peers"` | ||||||
|  | 	MagnetUri    string             `json:"MagnetUri"` | ||||||
|  | 	PublishDate  string             `json:"PublishDate"` | ||||||
|  | 	CategoryDesc string             `json:"CategoryDesc"` | ||||||
|  | 	Details      string             `json:"Details"` | ||||||
|  | 	Info         *RedAPITorrentInfo `json:"Info,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type RedAPITorrentInfo struct { | ||||||
|  | 	Quality interface{} `json:"quality,omitempty"` // Может быть string или number | ||||||
|  | 	Voices  []string    `json:"voices,omitempty"` | ||||||
|  | 	Types   []string    `json:"types,omitempty"` | ||||||
|  | 	Seasons []int       `json:"seasons,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Alloha API структуры для получения информации о фильмах | ||||||
|  | type AllohaResponse struct { | ||||||
|  | 	Data *AllohaData `json:"data"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type AllohaData struct { | ||||||
|  | 	Name         string `json:"name"` | ||||||
|  | 	OriginalName string `json:"original_name"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Опции поиска торрентов | ||||||
|  | type TorrentSearchOptions struct { | ||||||
|  | 	Season           *int | ||||||
|  | 	Quality          []string | ||||||
|  | 	MinQuality       string | ||||||
|  | 	MaxQuality       string | ||||||
|  | 	ExcludeQualities []string | ||||||
|  | 	HDR              *bool | ||||||
|  | 	HEVC             *bool | ||||||
|  | 	SortBy           string | ||||||
|  | 	SortOrder        string | ||||||
|  | 	GroupByQuality   bool | ||||||
|  | 	GroupBySeason    bool | ||||||
|  | 	ContentType      string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Модели для плееров | ||||||
|  | type PlayerResponse struct { | ||||||
|  | 	Type   string `json:"type"` | ||||||
|  | 	URL    string `json:"url"` | ||||||
|  | 	Iframe string `json:"iframe,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Модели для реакций | ||||||
|  | type Reaction struct { | ||||||
|  | 	ID      string `json:"id" bson:"_id,omitempty"` | ||||||
|  | 	UserID  string `json:"userId" bson:"userId"` | ||||||
|  | 	MediaID string `json:"mediaId" bson:"mediaId"` | ||||||
|  | 	Type    string `json:"type" bson:"type"` | ||||||
|  | 	Created string `json:"created" bson:"created"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type ReactionCounts struct { | ||||||
|  | 	Fire  int `json:"fire"` | ||||||
|  | 	Nice  int `json:"nice"` | ||||||
|  | 	Think int `json:"think"` | ||||||
|  | 	Bore  int `json:"bore"` | ||||||
|  | 	Shit  int `json:"shit"` | ||||||
|  | } | ||||||
							
								
								
									
										69
									
								
								pkg/models/user.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								pkg/models/user.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | package models | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"go.mongodb.org/mongo-driver/bson/primitive" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type User struct { | ||||||
|  | 	ID                  primitive.ObjectID `json:"id" bson:"_id,omitempty"` | ||||||
|  | 	Email               string             `json:"email" bson:"email" validate:"required,email"` | ||||||
|  | 	Password            string             `json:"-" bson:"password" validate:"required,min=6"` | ||||||
|  | 	Name                string             `json:"name" bson:"name" validate:"required"` | ||||||
|  | 	Avatar              string             `json:"avatar" bson:"avatar"` | ||||||
|  | 	Favorites           []string           `json:"favorites" bson:"favorites"` | ||||||
|  | 	Verified            bool               `json:"verified" bson:"verified"` | ||||||
|  | 	VerificationCode    string             `json:"-" bson:"verificationCode,omitempty"` | ||||||
|  | 	VerificationExpires time.Time          `json:"-" bson:"verificationExpires,omitempty"` | ||||||
|  | 	IsAdmin             bool               `json:"isAdmin" bson:"isAdmin"` | ||||||
|  | 	AdminVerified       bool               `json:"adminVerified" bson:"adminVerified"` | ||||||
|  | 	CreatedAt           time.Time          `json:"created_at" bson:"createdAt"` | ||||||
|  | 	UpdatedAt           time.Time          `json:"updated_at" bson:"updatedAt"` | ||||||
|  | 	Provider            string             `json:"provider,omitempty" bson:"provider,omitempty"` | ||||||
|  | 	GoogleID            string             `json:"googleId,omitempty" bson:"googleId,omitempty"` | ||||||
|  | 	RefreshTokens       []RefreshToken     `json:"-" bson:"refreshTokens,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type LoginRequest struct { | ||||||
|  | 	Email    string `json:"email" validate:"required,email"` | ||||||
|  | 	Password string `json:"password" validate:"required"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type RegisterRequest struct { | ||||||
|  | 	Email    string `json:"email" validate:"required,email"` | ||||||
|  | 	Password string `json:"password" validate:"required,min=6"` | ||||||
|  | 	Name     string `json:"name" validate:"required"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type AuthResponse struct { | ||||||
|  | 	Token        string `json:"token"` | ||||||
|  | 	RefreshToken string `json:"refreshToken"` | ||||||
|  | 	User         User   `json:"user"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type VerifyEmailRequest struct { | ||||||
|  | 	Email string `json:"email" validate:"required,email"` | ||||||
|  | 	Code  string `json:"code" validate:"required"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type ResendCodeRequest struct { | ||||||
|  | 	Email string `json:"email" validate:"required,email"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type RefreshToken struct { | ||||||
|  | 	Token     string    `json:"token" bson:"token"` | ||||||
|  | 	ExpiresAt time.Time `json:"expiresAt" bson:"expiresAt"` | ||||||
|  | 	CreatedAt time.Time `json:"createdAt" bson:"createdAt"` | ||||||
|  | 	UserAgent string    `json:"userAgent,omitempty" bson:"userAgent,omitempty"` | ||||||
|  | 	IPAddress string    `json:"ipAddress,omitempty" bson:"ipAddress,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type TokenPair struct { | ||||||
|  | 	AccessToken  string `json:"accessToken"` | ||||||
|  | 	RefreshToken string `json:"refreshToken"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type RefreshTokenRequest struct { | ||||||
|  | 	RefreshToken string `json:"refreshToken" validate:"required"` | ||||||
|  | } | ||||||
							
								
								
									
										91
									
								
								pkg/monitor/monitor.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								pkg/monitor/monitor.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | |||||||
|  | package monitor | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 	"strings" | ||||||
|  | 	"time" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // RequestMonitor создает middleware для мониторинга запросов в стиле htop | ||||||
|  | func RequestMonitor() func(http.Handler) http.Handler { | ||||||
|  | 	return func(next http.Handler) http.Handler { | ||||||
|  | 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||||
|  | 			start := time.Now() | ||||||
|  |  | ||||||
|  | 			// Создаем wrapper для ResponseWriter чтобы получить статус код | ||||||
|  | 			ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} | ||||||
|  |  | ||||||
|  | 			// Выполняем запрос | ||||||
|  | 			next.ServeHTTP(ww, r) | ||||||
|  |  | ||||||
|  | 			// Вычисляем время выполнения | ||||||
|  | 			duration := time.Since(start) | ||||||
|  |  | ||||||
|  | 			// Форматируем URL (обрезаем если слишком длинный) | ||||||
|  | 			url := r.URL.Path | ||||||
|  | 			if r.URL.RawQuery != "" { | ||||||
|  | 				url += "?" + r.URL.RawQuery | ||||||
|  | 			} | ||||||
|  | 			if len(url) > 60 { | ||||||
|  | 				url = url[:57] + "..." | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Определяем цвет статуса | ||||||
|  | 			statusColor := getStatusColor(ww.statusCode) | ||||||
|  | 			methodColor := getMethodColor(r.Method) | ||||||
|  |  | ||||||
|  | 			// Выводим информацию о запросе | ||||||
|  | 			fmt.Printf("\033[2K\r%s%-6s\033[0m %s%-3d\033[0m │ %-60s │ %6.2fms\n", | ||||||
|  | 				methodColor, r.Method, | ||||||
|  | 				statusColor, ww.statusCode, | ||||||
|  | 				url, | ||||||
|  | 				float64(duration.Nanoseconds())/1000000, | ||||||
|  | 			) | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type responseWriter struct { | ||||||
|  | 	http.ResponseWriter | ||||||
|  | 	statusCode int | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (rw *responseWriter) WriteHeader(code int) { | ||||||
|  | 	rw.statusCode = code | ||||||
|  | 	rw.ResponseWriter.WriteHeader(code) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // getStatusColor возвращает ANSI цвет для статус кода | ||||||
|  | func getStatusColor(status int) string { | ||||||
|  | 	switch { | ||||||
|  | 	case status >= 200 && status < 300: | ||||||
|  | 		return "\033[32m" // Зеленый | ||||||
|  | 	case status >= 300 && status < 400: | ||||||
|  | 		return "\033[33m" // Желтый | ||||||
|  | 	case status >= 400 && status < 500: | ||||||
|  | 		return "\033[31m" // Красный | ||||||
|  | 	case status >= 500: | ||||||
|  | 		return "\033[35m" // Фиолетовый | ||||||
|  | 	default: | ||||||
|  | 		return "\033[37m" // Белый | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // getMethodColor возвращает ANSI цвет для HTTP метода | ||||||
|  | func getMethodColor(method string) string { | ||||||
|  | 	switch strings.ToUpper(method) { | ||||||
|  | 	case "GET": | ||||||
|  | 		return "\033[34m" // Синий | ||||||
|  | 	case "POST": | ||||||
|  | 		return "\033[32m" // Зеленый | ||||||
|  | 	case "PUT": | ||||||
|  | 		return "\033[33m" // Желтый | ||||||
|  | 	case "DELETE": | ||||||
|  | 		return "\033[31m" // Красный | ||||||
|  | 	case "PATCH": | ||||||
|  | 		return "\033[36m" // Циан | ||||||
|  | 	default: | ||||||
|  | 		return "\033[37m" // Белый | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										208
									
								
								pkg/players/iframevideo.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										208
									
								
								pkg/players/iframevideo.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,208 @@ | |||||||
|  | package players | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"bytes" | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"mime/multipart" | ||||||
|  | 	"net/http" | ||||||
|  | 	"regexp" | ||||||
|  | 	"strconv" | ||||||
|  | 	"time" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // IframeVideoSearchResponse represents the search response from IframeVideo API | ||||||
|  | type IframeVideoSearchResponse struct { | ||||||
|  | 	Results []struct { | ||||||
|  | 		CID  int    `json:"cid"` | ||||||
|  | 		Path string `json:"path"` | ||||||
|  | 		Type string `json:"type"` | ||||||
|  | 	} `json:"results"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // IframeVideoResponse represents the video response from IframeVideo API | ||||||
|  | type IframeVideoResponse struct { | ||||||
|  | 	Source string `json:"src"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // IframeVideoPlayer implements the IframeVideo streaming service | ||||||
|  | type IframeVideoPlayer struct { | ||||||
|  | 	APIHost string | ||||||
|  | 	CDNHost string | ||||||
|  | 	Client  *http.Client | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // NewIframeVideoPlayer creates a new IframeVideo player instance | ||||||
|  | func NewIframeVideoPlayer() *IframeVideoPlayer { | ||||||
|  | 	return &IframeVideoPlayer{ | ||||||
|  | 		APIHost: "https://iframe.video", | ||||||
|  | 		CDNHost: "https://videoframe.space", | ||||||
|  | 		Client: &http.Client{ | ||||||
|  | 			Timeout: 8 * time.Second, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetStream gets streaming URL by Kinopoisk ID and IMDB ID | ||||||
|  | func (i *IframeVideoPlayer) GetStream(kinopoiskID, imdbID string) (*StreamResult, error) { | ||||||
|  | 	// First, search for content | ||||||
|  | 	searchResult, err := i.searchContent(kinopoiskID, imdbID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("search failed: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Get iframe content to extract token | ||||||
|  | 	token, err := i.extractToken(searchResult.Path) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("token extraction failed: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Get video URL | ||||||
|  | 	return i.getVideoURL(searchResult.CID, token, searchResult.Type) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // searchContent searches for content by Kinopoisk and IMDB IDs | ||||||
|  | func (i *IframeVideoPlayer) searchContent(kinopoiskID, imdbID string) (*struct { | ||||||
|  | 	CID  int | ||||||
|  | 	Path string | ||||||
|  | 	Type string | ||||||
|  | }, error) { | ||||||
|  | 	url := fmt.Sprintf("%s/api/v2/search?imdb=%s&kp=%s", i.APIHost, imdbID, kinopoiskID) | ||||||
|  |  | ||||||
|  | 	req, err := http.NewRequest("GET", url, nil) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	req.Header.Set("Accept", "application/json") | ||||||
|  | 	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36") | ||||||
|  |  | ||||||
|  | 	resp, err := i.Client.Do(req) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to fetch search results: %w", err) | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	if resp.StatusCode != http.StatusOK { | ||||||
|  | 		return nil, fmt.Errorf("API returned status: %d", resp.StatusCode) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var searchResp IframeVideoSearchResponse | ||||||
|  | 	if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to decode response: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if len(searchResp.Results) == 0 { | ||||||
|  | 		return nil, fmt.Errorf("content not found") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	result := searchResp.Results[0] | ||||||
|  | 	return &struct { | ||||||
|  | 		CID  int | ||||||
|  | 		Path string | ||||||
|  | 		Type string | ||||||
|  | 	}{ | ||||||
|  | 		CID:  result.CID, | ||||||
|  | 		Path: result.Path, | ||||||
|  | 		Type: result.Type, | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // extractToken extracts token from iframe HTML content | ||||||
|  | func (i *IframeVideoPlayer) extractToken(path string) (string, error) { | ||||||
|  | 	req, err := http.NewRequest("GET", path, nil) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", fmt.Errorf("failed to create request: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Set headers similar to C# implementation | ||||||
|  | 	req.Header.Set("DNT", "1") | ||||||
|  | 	req.Header.Set("Referer", i.CDNHost+"/") | ||||||
|  | 	req.Header.Set("Sec-Fetch-Dest", "iframe") | ||||||
|  | 	req.Header.Set("Sec-Fetch-Mode", "navigate") | ||||||
|  | 	req.Header.Set("Sec-Fetch-Site", "cross-site") | ||||||
|  | 	req.Header.Set("Upgrade-Insecure-Requests", "1") | ||||||
|  | 	req.Header.Set("sec-ch-ua", `"Google Chrome";v="113", "Chromium";v="113", "Not-A.Brand";v="24"`) | ||||||
|  | 	req.Header.Set("sec-ch-ua-mobile", "?0") | ||||||
|  | 	req.Header.Set("sec-ch-ua-platform", `"Windows"`) | ||||||
|  | 	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36") | ||||||
|  |  | ||||||
|  | 	resp, err := i.Client.Do(req) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", fmt.Errorf("failed to fetch iframe content: %w", err) | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	if resp.StatusCode != http.StatusOK { | ||||||
|  | 		return "", fmt.Errorf("iframe returned status: %d", resp.StatusCode) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	content, err := io.ReadAll(resp.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", fmt.Errorf("failed to read iframe content: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Extract token using regex as in C# implementation | ||||||
|  | 	re := regexp.MustCompile(`\/[^\/]+\/([^\/]+)\/iframe`) | ||||||
|  | 	matches := re.FindStringSubmatch(string(content)) | ||||||
|  | 	if len(matches) < 2 { | ||||||
|  | 		return "", fmt.Errorf("token not found in iframe content") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return matches[1], nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // getVideoURL gets video URL using extracted token | ||||||
|  | func (i *IframeVideoPlayer) getVideoURL(cid int, token, mediaType string) (*StreamResult, error) { | ||||||
|  | 	// Create multipart form data | ||||||
|  | 	var buf bytes.Buffer | ||||||
|  | 	writer := multipart.NewWriter(&buf) | ||||||
|  |  | ||||||
|  | 	writer.WriteField("token", token) | ||||||
|  | 	writer.WriteField("type", mediaType) | ||||||
|  | 	writer.WriteField("season", "") | ||||||
|  | 	writer.WriteField("episode", "") | ||||||
|  | 	writer.WriteField("mobile", "false") | ||||||
|  | 	writer.WriteField("id", strconv.Itoa(cid)) | ||||||
|  | 	writer.WriteField("qt", "480") | ||||||
|  |  | ||||||
|  | 	contentType := writer.FormDataContentType() | ||||||
|  | 	writer.Close() | ||||||
|  |  | ||||||
|  | 	req, err := http.NewRequest("POST", i.CDNHost+"/loadvideo", &buf) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	req.Header.Set("Content-Type", contentType) | ||||||
|  | 	req.Header.Set("Origin", i.CDNHost) | ||||||
|  | 	req.Header.Set("Referer", i.CDNHost+"/") | ||||||
|  | 	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36") | ||||||
|  |  | ||||||
|  | 	resp, err := i.Client.Do(req) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to fetch video URL: %w", err) | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	if resp.StatusCode != http.StatusOK { | ||||||
|  | 		return nil, fmt.Errorf("video API returned status: %d", resp.StatusCode) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var videoResp IframeVideoResponse | ||||||
|  | 	if err := json.NewDecoder(resp.Body).Decode(&videoResp); err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to decode video response: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if videoResp.Source == "" { | ||||||
|  | 		return nil, fmt.Errorf("video URL not found") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return &StreamResult{ | ||||||
|  | 		Success:   true, | ||||||
|  | 		StreamURL: videoResp.Source, | ||||||
|  | 		Provider:  "IframeVideo", | ||||||
|  | 		Type:      "direct", | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
							
								
								
									
										81
									
								
								pkg/players/rgshows.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								pkg/players/rgshows.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | |||||||
|  | package players | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 	"time" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // RgShowsResponse represents the response from RgShows API | ||||||
|  | type RgShowsResponse struct { | ||||||
|  | 	Stream *struct { | ||||||
|  | 		URL string `json:"url"` | ||||||
|  | 	} `json:"stream"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // RgShowsPlayer implements the RgShows streaming service | ||||||
|  | type RgShowsPlayer struct { | ||||||
|  | 	BaseURL string | ||||||
|  | 	Client  *http.Client | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // NewRgShowsPlayer creates a new RgShows player instance | ||||||
|  | func NewRgShowsPlayer() *RgShowsPlayer { | ||||||
|  | 	return &RgShowsPlayer{ | ||||||
|  | 		BaseURL: "https://rgshows.com", | ||||||
|  | 		Client: &http.Client{ | ||||||
|  | 			Timeout: 40 * time.Second, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetMovieStream gets streaming URL for a movie by TMDB ID | ||||||
|  | func (r *RgShowsPlayer) GetMovieStream(tmdbID string) (*StreamResult, error) { | ||||||
|  | 	url := fmt.Sprintf("%s/main/movie/%s", r.BaseURL, tmdbID) | ||||||
|  | 	return r.fetchStream(url) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetTVStream gets streaming URL for a TV show episode by TMDB ID, season and episode | ||||||
|  | func (r *RgShowsPlayer) GetTVStream(tmdbID string, season, episode int) (*StreamResult, error) { | ||||||
|  | 	url := fmt.Sprintf("%s/main/tv/%s/%d/%d", r.BaseURL, tmdbID, season, episode) | ||||||
|  | 	return r.fetchStream(url) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // fetchStream makes HTTP request to RgShows API and extracts stream URL | ||||||
|  | func (r *RgShowsPlayer) fetchStream(url string) (*StreamResult, error) { | ||||||
|  | 	req, err := http.NewRequest("GET", url, nil) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to create request: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Set headers similar to the C# implementation | ||||||
|  | 	req.Header.Set("Accept", "application/json") | ||||||
|  | 	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36") | ||||||
|  |  | ||||||
|  | 	resp, err := r.Client.Do(req) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to fetch stream: %w", err) | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	if resp.StatusCode != http.StatusOK { | ||||||
|  | 		return nil, fmt.Errorf("API returned status: %d", resp.StatusCode) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var rgResp RgShowsResponse | ||||||
|  | 	if err := json.NewDecoder(resp.Body).Decode(&rgResp); err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to decode response: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if rgResp.Stream == nil || rgResp.Stream.URL == "" { | ||||||
|  | 		return nil, fmt.Errorf("stream not found") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return &StreamResult{ | ||||||
|  | 		Success:   true, | ||||||
|  | 		StreamURL: rgResp.Stream.URL, | ||||||
|  | 		Provider:  "RgShows", | ||||||
|  | 		Type:      "direct", | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
							
								
								
									
										99
									
								
								pkg/players/types.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								pkg/players/types.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | |||||||
|  | package players | ||||||
|  |  | ||||||
|  | // StreamResult represents the result of a streaming request | ||||||
|  | type StreamResult struct { | ||||||
|  | 	Success   bool   `json:"success"` | ||||||
|  | 	StreamURL string `json:"stream_url,omitempty"` | ||||||
|  | 	Provider  string `json:"provider"` | ||||||
|  | 	Type      string `json:"type"` // "direct", "iframe", "hls", etc. | ||||||
|  | 	Error     string `json:"error,omitempty"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Player interface defines methods for streaming providers | ||||||
|  | type Player interface { | ||||||
|  | 	GetMovieStream(tmdbID string) (*StreamResult, error) | ||||||
|  | 	GetTVStream(tmdbID string, season, episode int) (*StreamResult, error) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // PlayersManager manages all available streaming players | ||||||
|  | type PlayersManager struct { | ||||||
|  | 	rgshows     *RgShowsPlayer | ||||||
|  | 	iframevideo *IframeVideoPlayer | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // NewPlayersManager creates a new players manager | ||||||
|  | func NewPlayersManager() *PlayersManager { | ||||||
|  | 	return &PlayersManager{ | ||||||
|  | 		rgshows:     NewRgShowsPlayer(), | ||||||
|  | 		iframevideo: NewIframeVideoPlayer(), | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetMovieStreams tries to get movie streams from all available providers | ||||||
|  | func (pm *PlayersManager) GetMovieStreams(tmdbID string) []*StreamResult { | ||||||
|  | 	var results []*StreamResult | ||||||
|  |  | ||||||
|  | 	// Try RgShows | ||||||
|  | 	if stream, err := pm.rgshows.GetMovieStream(tmdbID); err == nil { | ||||||
|  | 		results = append(results, stream) | ||||||
|  | 	} else { | ||||||
|  | 		results = append(results, &StreamResult{ | ||||||
|  | 			Success:  false, | ||||||
|  | 			Provider: "RgShows", | ||||||
|  | 			Error:    err.Error(), | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return results | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetTVStreams tries to get TV show streams from all available providers | ||||||
|  | func (pm *PlayersManager) GetTVStreams(tmdbID string, season, episode int) []*StreamResult { | ||||||
|  | 	var results []*StreamResult | ||||||
|  |  | ||||||
|  | 	// Try RgShows | ||||||
|  | 	if stream, err := pm.rgshows.GetTVStream(tmdbID, season, episode); err == nil { | ||||||
|  | 		results = append(results, stream) | ||||||
|  | 	} else { | ||||||
|  | 		results = append(results, &StreamResult{ | ||||||
|  | 			Success:  false, | ||||||
|  | 			Provider: "RgShows", | ||||||
|  | 			Error:    err.Error(), | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return results | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetMovieStreamByProvider gets movie stream from specific provider | ||||||
|  | func (pm *PlayersManager) GetMovieStreamByProvider(provider, tmdbID string) (*StreamResult, error) { | ||||||
|  | 	switch provider { | ||||||
|  | 	case "rgshows": | ||||||
|  | 		return pm.rgshows.GetMovieStream(tmdbID) | ||||||
|  | 	default: | ||||||
|  | 		return &StreamResult{ | ||||||
|  | 			Success:  false, | ||||||
|  | 			Provider: provider, | ||||||
|  | 			Error:    "provider not found", | ||||||
|  | 		}, nil | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetTVStreamByProvider gets TV stream from specific provider | ||||||
|  | func (pm *PlayersManager) GetTVStreamByProvider(provider, tmdbID string, season, episode int) (*StreamResult, error) { | ||||||
|  | 	switch provider { | ||||||
|  | 	case "rgshows": | ||||||
|  | 		return pm.rgshows.GetTVStream(tmdbID, season, episode) | ||||||
|  | 	default: | ||||||
|  | 		return &StreamResult{ | ||||||
|  | 			Success:  false, | ||||||
|  | 			Provider: provider, | ||||||
|  | 			Error:    "provider not found", | ||||||
|  | 		}, nil | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetStreamWithKinopoisk gets stream using Kinopoisk ID and IMDB ID (for IframeVideo) | ||||||
|  | func (pm *PlayersManager) GetStreamWithKinopoisk(kinopoiskID, imdbID string) (*StreamResult, error) { | ||||||
|  | 	return pm.iframevideo.GetStream(kinopoiskID, imdbID) | ||||||
|  | } | ||||||
							
								
								
									
										654
									
								
								pkg/services/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										654
									
								
								pkg/services/auth.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,654 @@ | |||||||
|  | package services | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"errors" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"math/rand" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 	"sync" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"github.com/golang-jwt/jwt/v5" | ||||||
|  | 	"github.com/google/uuid" | ||||||
|  | 	"go.mongodb.org/mongo-driver/bson" | ||||||
|  | 	"go.mongodb.org/mongo-driver/bson/primitive" | ||||||
|  | 	"go.mongodb.org/mongo-driver/mongo" | ||||||
|  | 	"golang.org/x/crypto/bcrypt" | ||||||
|  | 	"golang.org/x/oauth2" | ||||||
|  | 	"golang.org/x/oauth2/google" | ||||||
|  |  | ||||||
|  | 	"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 | ||||||
|  | 	googleClientID     string | ||||||
|  | 	googleClientSecret string | ||||||
|  | 	googleRedirectURL  string | ||||||
|  | 	frontendURL        string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Reaction represents a reaction entry in the database. | ||||||
|  | type Reaction struct { | ||||||
|  | 	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, | ||||||
|  | 		googleClientID:     googleClientID, | ||||||
|  | 		googleClientSecret: googleClientSecret, | ||||||
|  | 		googleRedirectURL:  googleRedirectURL, | ||||||
|  | 		frontendURL:        frontendURL, | ||||||
|  | 	} | ||||||
|  | 	return service | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *AuthService) googleOAuthConfig() *oauth2.Config { | ||||||
|  | 	redirectURL := s.googleRedirectURL | ||||||
|  | 	if redirectURL == "" && s.baseURL != "" { | ||||||
|  | 		redirectURL = fmt.Sprintf("%s/api/v1/auth/google/callback", s.baseURL) | ||||||
|  | 	} | ||||||
|  | 	return &oauth2.Config{ | ||||||
|  | 		ClientID:     s.googleClientID, | ||||||
|  | 		ClientSecret: s.googleClientSecret, | ||||||
|  | 		RedirectURL:  redirectURL, | ||||||
|  | 		Scopes:       []string{"openid", "email", "profile"}, | ||||||
|  | 		Endpoint:     google.Endpoint, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *AuthService) GetGoogleLoginURL(state string) (string, error) { | ||||||
|  | 	cfg := s.googleOAuthConfig() | ||||||
|  | 	if cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" { | ||||||
|  | 		return "", errors.New("google oauth not configured") | ||||||
|  | 	} | ||||||
|  | 	return cfg.AuthCodeURL(state, oauth2.AccessTypeOffline), nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type googleUserInfo struct { | ||||||
|  | 	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 | ||||||
|  | func (s *AuthService) BuildFrontendRedirect(token string, authErr string) (string, bool) { | ||||||
|  | 	if s.frontendURL == "" { | ||||||
|  | 		return "", false | ||||||
|  | 	} | ||||||
|  | 	if authErr != "" { | ||||||
|  | 		u, _ := url.Parse(s.frontendURL + "/login") | ||||||
|  | 		q := u.Query() | ||||||
|  | 		q.Set("oauth", "google") | ||||||
|  | 		q.Set("error", authErr) | ||||||
|  | 		u.RawQuery = q.Encode() | ||||||
|  | 		return u.String(), true | ||||||
|  | 	} | ||||||
|  | 	u, _ := url.Parse(s.frontendURL + "/auth/callback") | ||||||
|  | 	q := u.Query() | ||||||
|  | 	q.Set("provider", "google") | ||||||
|  | 	q.Set("token", token) | ||||||
|  | 	u.RawQuery = q.Encode() | ||||||
|  | 	return u.String(), true | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*models.AuthResponse, error) { | ||||||
|  | 	cfg := s.googleOAuthConfig() | ||||||
|  | 	tok, err := cfg.Exchange(ctx, code) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to exchange code: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	client := cfg.Client(ctx, tok) | ||||||
|  | 	resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo") | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to fetch userinfo: %w", err) | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  | 	if resp.StatusCode != http.StatusOK { | ||||||
|  | 		return nil, fmt.Errorf("userinfo request failed: status %d", resp.StatusCode) | ||||||
|  | 	} | ||||||
|  | 	body, err := io.ReadAll(resp.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	var gUser googleUserInfo | ||||||
|  | 	if err := json.Unmarshal(body, &gUser); err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to parse userinfo: %w", err) | ||||||
|  | 	} | ||||||
|  | 	if gUser.Email == "" { | ||||||
|  | 		return nil, errors.New("email not provided by Google") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	collection := s.db.Collection("users") | ||||||
|  |  | ||||||
|  | 	// Try by googleId first | ||||||
|  | 	var user models.User | ||||||
|  | 	err = collection.FindOne(ctx, bson.M{"googleId": gUser.Sub}).Decode(&user) | ||||||
|  | 	if err == mongo.ErrNoDocuments { | ||||||
|  | 		// Try by email | ||||||
|  | 		err = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user) | ||||||
|  | 	} | ||||||
|  | 	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, | ||||||
|  | 			AdminVerified: false, | ||||||
|  | 			CreatedAt:     time.Now(), | ||||||
|  | 			UpdatedAt:     time.Now(), | ||||||
|  | 			Provider:      "google", | ||||||
|  | 			GoogleID:      gUser.Sub, | ||||||
|  | 		} | ||||||
|  | 		if _, err := collection.InsertOne(ctx, user); err != nil { | ||||||
|  | 			return nil, err | ||||||
|  | 		} | ||||||
|  | 	} else if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} else { | ||||||
|  | 		// Existing user: ensure fields | ||||||
|  | 		update := bson.M{ | ||||||
|  | 			"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 | ||||||
|  | 		} | ||||||
|  | 		_, _ = collection.UpdateOne(ctx, bson.M{"_id": user.ID}, bson.M{"$set": update}) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Generate JWT | ||||||
|  | 	if user.ID.IsZero() { | ||||||
|  | 		// If we created user above, we already have user.ID set; else fetch updated | ||||||
|  | 		_ = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user) | ||||||
|  | 	} | ||||||
|  | 	tokenPair, err := s.generateTokenPair(user.ID.Hex(), "", "") | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return &models.AuthResponse{ | ||||||
|  | 		Token:        tokenPair.AccessToken, | ||||||
|  | 		RefreshToken: tokenPair.RefreshToken, | ||||||
|  | 		User:         user, | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // generateVerificationCode creates a 6-digit verification code. | ||||||
|  | func (s *AuthService) generateVerificationCode() string { | ||||||
|  | 	return fmt.Sprintf("%06d", rand.Intn(900000)+100000) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Register registers a new user. | ||||||
|  | func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) { | ||||||
|  | 	collection := s.db.Collection("users") | ||||||
|  |  | ||||||
|  | 	var existingUser models.User | ||||||
|  | 	err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser) | ||||||
|  | 	if err == nil { | ||||||
|  | 		return nil, errors.New("email already registered") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	code := s.generateVerificationCode() | ||||||
|  | 	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, | ||||||
|  | 		VerificationExpires: codeExpires, | ||||||
|  | 		IsAdmin:             false, | ||||||
|  | 		AdminVerified:       false, | ||||||
|  | 		CreatedAt:           time.Now(), | ||||||
|  | 		UpdatedAt:           time.Now(), | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	_, err = collection.InsertOne(context.Background(), user) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if s.emailService != nil { | ||||||
|  | 		go s.emailService.SendVerificationEmail(user.Email, code) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return map[string]interface{}{ | ||||||
|  | 		"success": true, | ||||||
|  | 		"message": "Registered. Check email for verification code.", | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Login authenticates a user. | ||||||
|  | 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 { | ||||||
|  | 		return nil, errors.New("User not found") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if !user.Verified { | ||||||
|  | 		return nil, errors.New("Account not activated. Please verify your email.") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, errors.New("Invalid password") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	tokenPair, err := s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return &models.AuthResponse{ | ||||||
|  | 		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") | ||||||
|  |  | ||||||
|  | 	objectID, err := primitive.ObjectIDFromHex(userID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var user models.User | ||||||
|  | 	err = collection.FindOne(context.Background(), bson.M{"_id": objectID}).Decode(&user) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return &user, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // UpdateUser updates a user's information. | ||||||
|  | func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) { | ||||||
|  | 	collection := s.db.Collection("users") | ||||||
|  |  | ||||||
|  | 	objectID, err := primitive.ObjectIDFromHex(userID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	updates["updated_at"] = time.Now() | ||||||
|  |  | ||||||
|  | 	_, err = collection.UpdateOne( | ||||||
|  | 		context.Background(), | ||||||
|  | 		bson.M{"_id": objectID}, | ||||||
|  | 		bson.M{"$set": updates}, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return s.GetUserByID(userID) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // generateJWT generates a new JWT for a given user ID. | ||||||
|  | func (s *AuthService) generateJWT(userID string) (string, error) { | ||||||
|  | 	claims := jwt.MapClaims{ | ||||||
|  | 		"user_id": userID, | ||||||
|  | 		"exp":     time.Now().Add(time.Hour * 1).Unix(), // Сократил время жизни до 1 часа | ||||||
|  | 		"iat":     time.Now().Unix(), | ||||||
|  | 		"jti":     uuid.New().String(), | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | ||||||
|  | 	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") | ||||||
|  |  | ||||||
|  | 	var user models.User | ||||||
|  | 	err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, errors.New("user not found") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if user.Verified { | ||||||
|  | 		return map[string]interface{}{ | ||||||
|  | 			"success": true, | ||||||
|  | 			"message": "Email already verified", | ||||||
|  | 		}, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) { | ||||||
|  | 		return nil, errors.New("invalid or expired verification code") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	_, err = collection.UpdateOne( | ||||||
|  | 		context.Background(), | ||||||
|  | 		bson.M{"email": req.Email}, | ||||||
|  | 		bson.M{ | ||||||
|  | 			"$set": bson.M{"verified": true}, | ||||||
|  | 			"$unset": bson.M{ | ||||||
|  | 				"verificationCode":    "", | ||||||
|  | 				"verificationExpires": "", | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return map[string]interface{}{ | ||||||
|  | 		"success": true, | ||||||
|  | 		"message": "Email verified successfully", | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ResendVerificationCode sends a new verification email. | ||||||
|  | func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, 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 { | ||||||
|  | 		return nil, errors.New("user not found") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if user.Verified { | ||||||
|  | 		return nil, errors.New("email already verified") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	code := s.generateVerificationCode() | ||||||
|  | 	codeExpires := time.Now().Add(10 * time.Minute) | ||||||
|  |  | ||||||
|  | 	_, err = collection.UpdateOne( | ||||||
|  | 		context.Background(), | ||||||
|  | 		bson.M{"email": req.Email}, | ||||||
|  | 		bson.M{ | ||||||
|  | 			"$set": bson.M{ | ||||||
|  | 				"verificationCode":    code, | ||||||
|  | 				"verificationExpires": codeExpires, | ||||||
|  | 			}, | ||||||
|  | 		}, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if s.emailService != nil { | ||||||
|  | 		go s.emailService.SendVerificationEmail(user.Email, code) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return map[string]interface{}{ | ||||||
|  | 		"success": true, | ||||||
|  | 		"message": "Verification code sent to your email", | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // DeleteAccount deletes a user and all associated data. | ||||||
|  | func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error { | ||||||
|  | 	objectID, err := primitive.ObjectIDFromHex(userID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("invalid user ID format: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Step 1: Find user reactions and remove them from cub.rip | ||||||
|  | 	if s.baseURL != "" { // Changed from cubAPIURL to baseURL | ||||||
|  | 		reactionsCollection := s.db.Collection("reactions") | ||||||
|  | 		var userReactions []Reaction | ||||||
|  | 		cursor, err := reactionsCollection.Find(ctx, bson.M{"userId": objectID}) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return fmt.Errorf("failed to find user reactions: %w", err) | ||||||
|  | 		} | ||||||
|  | 		if err = cursor.All(ctx, &userReactions); err != nil { | ||||||
|  | 			return fmt.Errorf("failed to decode user reactions: %w", err) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		var wg sync.WaitGroup | ||||||
|  | 		client := &http.Client{Timeout: 10 * time.Second} | ||||||
|  |  | ||||||
|  | 		for _, reaction := range userReactions { | ||||||
|  | 			wg.Add(1) | ||||||
|  | 			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" | ||||||
|  | 				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) | ||||||
|  | 				} | ||||||
|  | 			}(reaction) | ||||||
|  | 		} | ||||||
|  | 		wg.Wait() | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Step 2: Delete all user-related data from the database | ||||||
|  | 	usersCollection := s.db.Collection("users") | ||||||
|  | 	favoritesCollection := s.db.Collection("favorites") | ||||||
|  | 	reactionsCollection := s.db.Collection("reactions") | ||||||
|  |  | ||||||
|  | 	_, err = usersCollection.DeleteOne(ctx, bson.M{"_id": objectID}) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to delete user: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	_, err = favoritesCollection.DeleteMany(ctx, bson.M{"userId": objectID}) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to delete user favorites: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	_, err = reactionsCollection.DeleteMany(ctx, bson.M{"userId": objectID}) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("failed to delete user reactions: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
							
								
								
									
										150
									
								
								pkg/services/email.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								pkg/services/email.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,150 @@ | |||||||
|  | package services | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/smtp" | ||||||
|  | 	"strings" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/config" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type EmailService struct { | ||||||
|  | 	config *config.Config | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewEmailService(cfg *config.Config) *EmailService { | ||||||
|  | 	return &EmailService{ | ||||||
|  | 		config: cfg, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type EmailOptions struct { | ||||||
|  | 	To      []string | ||||||
|  | 	Subject string | ||||||
|  | 	Body    string | ||||||
|  | 	IsHTML  bool | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *EmailService) SendEmail(options *EmailOptions) error { | ||||||
|  | 	if s.config.GmailUser == "" || s.config.GmailPassword == "" { | ||||||
|  | 		return fmt.Errorf("Gmail credentials not configured") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Gmail SMTP конфигурация | ||||||
|  | 	smtpHost := "smtp.gmail.com" | ||||||
|  | 	smtpPort := "587" | ||||||
|  | 	auth := smtp.PlainAuth("", s.config.GmailUser, s.config.GmailPassword, smtpHost) | ||||||
|  |  | ||||||
|  | 	// Создаем заголовки email | ||||||
|  | 	headers := make(map[string]string) | ||||||
|  | 	headers["From"] = s.config.GmailUser | ||||||
|  | 	headers["To"] = strings.Join(options.To, ",") | ||||||
|  | 	headers["Subject"] = options.Subject | ||||||
|  |  | ||||||
|  | 	if options.IsHTML { | ||||||
|  | 		headers["MIME-Version"] = "1.0" | ||||||
|  | 		headers["Content-Type"] = "text/html; charset=UTF-8" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Формируем сообщение | ||||||
|  | 	message := "" | ||||||
|  | 	for key, value := range headers { | ||||||
|  | 		message += fmt.Sprintf("%s: %s\r\n", key, value) | ||||||
|  | 	} | ||||||
|  | 	message += "\r\n" + options.Body | ||||||
|  |  | ||||||
|  | 	// Отправляем email | ||||||
|  | 	err := smtp.SendMail( | ||||||
|  | 		smtpHost+":"+smtpPort, | ||||||
|  | 		auth, | ||||||
|  | 		s.config.GmailUser, | ||||||
|  | 		options.To, | ||||||
|  | 		[]byte(message), | ||||||
|  | 	) | ||||||
|  |  | ||||||
|  | 	return err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Предустановленные шаблоны email | ||||||
|  | func (s *EmailService) SendVerificationEmail(userEmail, code string) error { | ||||||
|  | 	options := &EmailOptions{ | ||||||
|  | 		To:      []string{userEmail}, | ||||||
|  | 		Subject: "Подтверждение регистрации Neo Movies", | ||||||
|  | 		Body: fmt.Sprintf(` | ||||||
|  | 			<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;"> | ||||||
|  | 				<h1 style="color: #2196f3;">Neo Movies</h1> | ||||||
|  | 				<p>Здравствуйте!</p> | ||||||
|  | 				<p>Для завершения регистрации введите этот код:</p> | ||||||
|  | 				<div style=" | ||||||
|  | 					background: #f5f5f5; | ||||||
|  | 					padding: 20px; | ||||||
|  | 					border-radius: 8px; | ||||||
|  | 					text-align: center; | ||||||
|  | 					font-size: 24px; | ||||||
|  | 					letter-spacing: 4px; | ||||||
|  | 					margin: 20px 0; | ||||||
|  | 				"> | ||||||
|  | 					%s | ||||||
|  | 				</div> | ||||||
|  | 				<p>Код действителен в течение 10 минут.</p> | ||||||
|  | 				<p>Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо.</p> | ||||||
|  | 			</div> | ||||||
|  | 		`, code), | ||||||
|  | 		IsHTML: true, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return s.SendEmail(options) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *EmailService) SendPasswordResetEmail(userEmail, resetToken string) error { | ||||||
|  | 	resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, resetToken) | ||||||
|  |  | ||||||
|  | 	options := &EmailOptions{ | ||||||
|  | 		To:      []string{userEmail}, | ||||||
|  | 		Subject: "Сброс пароля Neo Movies", | ||||||
|  | 		Body: fmt.Sprintf(` | ||||||
|  | 			<html> | ||||||
|  | 			<body> | ||||||
|  | 				<h2>Сброс пароля</h2> | ||||||
|  | 				<p>Вы запросили сброс пароля для вашего аккаунта Neo Movies.</p> | ||||||
|  | 				<p>Нажмите на ссылку ниже, чтобы создать новый пароль:</p> | ||||||
|  | 				<p><a href="%s">Сбросить пароль</a></p> | ||||||
|  | 				<p>Ссылка действительна в течение 1 часа.</p> | ||||||
|  | 				<p>Если вы не запрашивали сброс пароля, проигнорируйте это сообщение.</p> | ||||||
|  | 				<br> | ||||||
|  | 				<p>С уважением,<br>Команда Neo Movies</p> | ||||||
|  | 			</body> | ||||||
|  | 			</html> | ||||||
|  | 		`, resetURL), | ||||||
|  | 		IsHTML: true, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return s.SendEmail(options) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *EmailService) SendMovieRecommendationEmail(userEmail, userName string, movies []string) error { | ||||||
|  | 	moviesList := "" | ||||||
|  | 	for _, movie := range movies { | ||||||
|  | 		moviesList += fmt.Sprintf("<li>%s</li>", movie) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	options := &EmailOptions{ | ||||||
|  | 		To:      []string{userEmail}, | ||||||
|  | 		Subject: "Новые рекомендации фильмов от Neo Movies", | ||||||
|  | 		Body: fmt.Sprintf(` | ||||||
|  | 			<html> | ||||||
|  | 			<body> | ||||||
|  | 				<h2>Привет, %s!</h2> | ||||||
|  | 				<p>У нас есть новые рекомендации фильмов специально для вас:</p> | ||||||
|  | 				<ul>%s</ul> | ||||||
|  | 				<p>Заходите в приложение, чтобы узнать больше деталей!</p> | ||||||
|  | 				<br> | ||||||
|  | 				<p>С уважением,<br>Команда Neo Movies</p> | ||||||
|  | 			</body> | ||||||
|  | 			</html> | ||||||
|  | 		`, userName, moviesList), | ||||||
|  | 		IsHTML: true, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return s.SendEmail(options) | ||||||
|  | } | ||||||
							
								
								
									
										184
									
								
								pkg/services/favorites.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								pkg/services/favorites.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,184 @@ | |||||||
|  | package services | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"fmt" | ||||||
|  | 	"strconv" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"go.mongodb.org/mongo-driver/bson" | ||||||
|  | 	"go.mongodb.org/mongo-driver/mongo" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type FavoritesService struct { | ||||||
|  | 	db   *mongo.Database | ||||||
|  | 	tmdb *TMDBService | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewFavoritesService(db *mongo.Database, tmdb *TMDBService) *FavoritesService { | ||||||
|  | 	return &FavoritesService{ | ||||||
|  | 		db:   db, | ||||||
|  | 		tmdb: tmdb, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *FavoritesService) AddToFavorites(userID, mediaID, mediaType string) error { | ||||||
|  | 	collection := s.db.Collection("favorites") | ||||||
|  |  | ||||||
|  | 	// Проверяем, не добавлен ли уже в избранное | ||||||
|  | 	filter := bson.M{ | ||||||
|  | 		"userId":    userID, | ||||||
|  | 		"mediaId":   mediaID, | ||||||
|  | 		"mediaType": mediaType, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var existingFavorite models.Favorite | ||||||
|  | 	err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite) | ||||||
|  | 	if err == nil { | ||||||
|  | 		// Уже в избранном | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var title, posterPath string | ||||||
|  |  | ||||||
|  | 	// Получаем информацию из TMDB в зависимости от типа медиа | ||||||
|  | 	mediaIDInt, err := strconv.Atoi(mediaID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("invalid media ID: %s", mediaID) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if mediaType == "movie" { | ||||||
|  | 		movie, err := s.tmdb.GetMovie(mediaIDInt, "en-US") | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 		title = movie.Title | ||||||
|  | 		posterPath = movie.PosterPath | ||||||
|  | 	} else if mediaType == "tv" { | ||||||
|  | 		tv, err := s.tmdb.GetTVShow(mediaIDInt, "en-US") | ||||||
|  | 		if err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 		title = tv.Name | ||||||
|  | 		posterPath = tv.PosterPath | ||||||
|  | 	} else { | ||||||
|  | 		return fmt.Errorf("invalid media type: %s", mediaType) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Формируем полный URL для постера | ||||||
|  | 	if posterPath != "" { | ||||||
|  | 		posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	favorite := models.Favorite{ | ||||||
|  | 		UserID:     userID, | ||||||
|  | 		MediaID:    mediaID, | ||||||
|  | 		MediaType:  mediaType, | ||||||
|  | 		Title:      title, | ||||||
|  | 		PosterPath: posterPath, | ||||||
|  | 		CreatedAt:  time.Now(), | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	_, err = collection.InsertOne(context.Background(), favorite) | ||||||
|  | 	return err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // AddToFavoritesWithInfo adds media to favorites with provided media information | ||||||
|  | func (s *FavoritesService) AddToFavoritesWithInfo(userID, mediaID, mediaType string, mediaInfo *models.MediaInfo) error { | ||||||
|  | 	collection := s.db.Collection("favorites") | ||||||
|  |  | ||||||
|  | 	// Проверяем, не добавлен ли уже в избранное | ||||||
|  | 	filter := bson.M{ | ||||||
|  | 		"userId":    userID, | ||||||
|  | 		"mediaId":   mediaID, | ||||||
|  | 		"mediaType": mediaType, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var existingFavorite models.Favorite | ||||||
|  | 	err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite) | ||||||
|  | 	if err == nil { | ||||||
|  | 		// Уже в избранном | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Формируем полный URL для постера если он есть | ||||||
|  | 	posterPath := mediaInfo.PosterPath | ||||||
|  | 	if posterPath != "" && posterPath[0] == '/' { | ||||||
|  | 		posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	favorite := models.Favorite{ | ||||||
|  | 		UserID:     userID, | ||||||
|  | 		MediaID:    mediaID, | ||||||
|  | 		MediaType:  mediaType, | ||||||
|  | 		Title:      mediaInfo.Title, | ||||||
|  | 		PosterPath: posterPath, | ||||||
|  | 		CreatedAt:  time.Now(), | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	_, err = collection.InsertOne(context.Background(), favorite) | ||||||
|  | 	return err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *FavoritesService) RemoveFromFavorites(userID, mediaID, mediaType string) error { | ||||||
|  | 	collection := s.db.Collection("favorites") | ||||||
|  |  | ||||||
|  | 	filter := bson.M{ | ||||||
|  | 		"userId":    userID, | ||||||
|  | 		"mediaId":   mediaID, | ||||||
|  | 		"mediaType": mediaType, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	_, err := collection.DeleteOne(context.Background(), filter) | ||||||
|  | 	return err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *FavoritesService) GetFavorites(userID string) ([]models.Favorite, error) { | ||||||
|  | 	collection := s.db.Collection("favorites") | ||||||
|  |  | ||||||
|  | 	filter := bson.M{ | ||||||
|  | 		"userId": userID, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	cursor, err := collection.Find(context.Background(), filter) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	defer cursor.Close(context.Background()) | ||||||
|  |  | ||||||
|  | 	var favorites []models.Favorite | ||||||
|  | 	err = cursor.All(context.Background(), &favorites) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Возвращаем пустой массив вместо nil если нет избранных | ||||||
|  | 	if favorites == nil { | ||||||
|  | 		favorites = []models.Favorite{} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return favorites, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *FavoritesService) IsFavorite(userID, mediaID, mediaType string) (bool, error) { | ||||||
|  | 	collection := s.db.Collection("favorites") | ||||||
|  |  | ||||||
|  | 	filter := bson.M{ | ||||||
|  | 		"userId":    userID, | ||||||
|  | 		"mediaId":   mediaID, | ||||||
|  | 		"mediaType": mediaType, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var favorite models.Favorite | ||||||
|  | 	err := collection.FindOne(context.Background(), filter).Decode(&favorite) | ||||||
|  | 	if err != nil { | ||||||
|  | 		if err == mongo.ErrNoDocuments { | ||||||
|  | 			return false, nil | ||||||
|  | 		} | ||||||
|  | 		return false, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return true, nil | ||||||
|  | } | ||||||
							
								
								
									
										53
									
								
								pkg/services/movie.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								pkg/services/movie.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | |||||||
|  | package services | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"go.mongodb.org/mongo-driver/mongo" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type MovieService struct { | ||||||
|  | 	tmdb *TMDBService | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService { | ||||||
|  | 	return &MovieService{ | ||||||
|  | 		tmdb: tmdb, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *MovieService) Search(query string, page int, language, region string, year int) (*models.TMDBResponse, error) { | ||||||
|  | 	return s.tmdb.SearchMovies(query, page, language, region, year) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *MovieService) GetByID(id int, language string) (*models.Movie, error) { | ||||||
|  | 	return s.tmdb.GetMovie(id, language) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *MovieService) GetPopular(page int, language, region string) (*models.TMDBResponse, error) { | ||||||
|  | 	return s.tmdb.GetPopularMovies(page, language, region) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *MovieService) GetTopRated(page int, language, region string) (*models.TMDBResponse, error) { | ||||||
|  | 	return s.tmdb.GetTopRatedMovies(page, language, region) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *MovieService) GetUpcoming(page int, language, region string) (*models.TMDBResponse, error) { | ||||||
|  | 	return s.tmdb.GetUpcomingMovies(page, language, region) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *MovieService) GetNowPlaying(page int, language, region string) (*models.TMDBResponse, error) { | ||||||
|  | 	return s.tmdb.GetNowPlayingMovies(page, language, region) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *MovieService) GetRecommendations(id, page int, language string) (*models.TMDBResponse, error) { | ||||||
|  | 	return s.tmdb.GetMovieRecommendations(id, page, language) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBResponse, error) { | ||||||
|  | 	return s.tmdb.GetSimilarMovies(id, page, language) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) { | ||||||
|  | 	return s.tmdb.GetMovieExternalIDs(id) | ||||||
|  | } | ||||||
							
								
								
									
										190
									
								
								pkg/services/reactions.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								pkg/services/reactions.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,190 @@ | |||||||
|  | package services | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"go.mongodb.org/mongo-driver/bson" | ||||||
|  | 	"go.mongodb.org/mongo-driver/mongo" | ||||||
|  | 	"go.mongodb.org/mongo-driver/mongo/options" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/config" | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type ReactionsService struct { | ||||||
|  | 	db     *mongo.Database | ||||||
|  | 	client *http.Client | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewReactionsService(db *mongo.Database) *ReactionsService { | ||||||
|  | 	return &ReactionsService{ | ||||||
|  | 		db:     db, | ||||||
|  | 		client: &http.Client{}, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | var validReactions = []string{"fire", "nice", "think", "bore", "shit"} | ||||||
|  |  | ||||||
|  | // Получить счетчики реакций для медиа из внешнего API (cub.rip) | ||||||
|  | func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) { | ||||||
|  | 	cubID := fmt.Sprintf("%s_%s", mediaType, mediaID) | ||||||
|  |  | ||||||
|  | 	resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", config.CubAPIBaseURL, cubID)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return &models.ReactionCounts{}, nil | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	if resp.StatusCode != http.StatusOK { | ||||||
|  | 		return &models.ReactionCounts{}, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	body, err := io.ReadAll(resp.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return &models.ReactionCounts{}, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var response struct { | ||||||
|  | 		Result []struct { | ||||||
|  | 			Type    string `json:"type"` | ||||||
|  | 			Counter int    `json:"counter"` | ||||||
|  | 		} `json:"result"` | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err := json.Unmarshal(body, &response); err != nil { | ||||||
|  | 		return &models.ReactionCounts{}, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	counts := &models.ReactionCounts{} | ||||||
|  | 	for _, reaction := range response.Result { | ||||||
|  | 		switch reaction.Type { | ||||||
|  | 		case "fire": | ||||||
|  | 			counts.Fire = reaction.Counter | ||||||
|  | 		case "nice": | ||||||
|  | 			counts.Nice = reaction.Counter | ||||||
|  | 		case "think": | ||||||
|  | 			counts.Think = reaction.Counter | ||||||
|  | 		case "bore": | ||||||
|  | 			counts.Bore = reaction.Counter | ||||||
|  | 		case "shit": | ||||||
|  | 			counts.Shit = reaction.Counter | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return counts, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *ReactionsService) GetMyReaction(userID, mediaType, mediaID string) (string, error) { | ||||||
|  | 	collection := s.db.Collection("reactions") | ||||||
|  | 	ctx := context.Background() | ||||||
|  |  | ||||||
|  | 	var result struct { | ||||||
|  | 		Type string `bson:"type"` | ||||||
|  | 	} | ||||||
|  | 	err := collection.FindOne(ctx, bson.M{ | ||||||
|  | 		"userId":    userID, | ||||||
|  | 		"mediaType": mediaType, | ||||||
|  | 		"mediaId":   mediaID, | ||||||
|  | 	}).Decode(&result) | ||||||
|  | 	if err != nil { | ||||||
|  | 		if err == mongo.ErrNoDocuments { | ||||||
|  | 			return "", nil | ||||||
|  | 		} | ||||||
|  | 		return "", err | ||||||
|  | 	} | ||||||
|  | 	return result.Type, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *ReactionsService) SetReaction(userID, mediaType, mediaID, reactionType string) error { | ||||||
|  | 	if !s.isValidReactionType(reactionType) { | ||||||
|  | 		return fmt.Errorf("invalid reaction type") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	collection := s.db.Collection("reactions") | ||||||
|  | 	ctx := context.Background() | ||||||
|  |  | ||||||
|  | 	_, err := collection.UpdateOne( | ||||||
|  | 		ctx, | ||||||
|  | 		bson.M{"userId": userID, "mediaType": mediaType, "mediaId": mediaID}, | ||||||
|  | 		bson.M{"$set": bson.M{"type": reactionType, "updatedAt": time.Now()}}, | ||||||
|  | 		options.Update().SetUpsert(true), | ||||||
|  | 	) | ||||||
|  | 	if err == nil { | ||||||
|  | 		go s.sendReactionToCub(fmt.Sprintf("%s_%s", mediaType, mediaID), reactionType) | ||||||
|  | 	} | ||||||
|  | 	return err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *ReactionsService) RemoveReaction(userID, mediaType, mediaID string) error { | ||||||
|  | 	collection := s.db.Collection("reactions") | ||||||
|  | 	ctx := context.Background() | ||||||
|  |  | ||||||
|  | 	_, err := collection.DeleteOne(ctx, bson.M{ | ||||||
|  | 		"userId":    userID, | ||||||
|  | 		"mediaType": mediaType, | ||||||
|  | 		"mediaId":   mediaID, | ||||||
|  | 	}) | ||||||
|  |  | ||||||
|  | 	fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID) | ||||||
|  | 	go s.sendReactionToCub(fullMediaID, "remove") | ||||||
|  |  | ||||||
|  | 	return err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Получить все реакции пользователя | ||||||
|  | func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models.Reaction, error) { | ||||||
|  | 	collection := s.db.Collection("reactions") | ||||||
|  |  | ||||||
|  | 	ctx := context.Background() | ||||||
|  | 	cursor, err := collection.Find(ctx, bson.M{"userId": userID}) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	defer cursor.Close(ctx) | ||||||
|  |  | ||||||
|  | 	var reactions []models.Reaction | ||||||
|  | 	if err := cursor.All(ctx, &reactions); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return reactions, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *ReactionsService) isValidReactionType(reactionType string) bool { | ||||||
|  | 	for _, valid := range validReactions { | ||||||
|  | 		if valid == reactionType { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return false | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Отправка реакции в cub.rip API (асинхронно) | ||||||
|  | func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) { | ||||||
|  | 	url := fmt.Sprintf("%s/reactions/set", config.CubAPIBaseURL) | ||||||
|  |  | ||||||
|  | 	data := map[string]string{ | ||||||
|  | 		"mediaId": mediaID, | ||||||
|  | 		"type":    reactionType, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	_, err := json.Marshal(data) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType)) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	if resp.StatusCode == http.StatusOK { | ||||||
|  | 		fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType) | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										515
									
								
								pkg/services/tmdb.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										515
									
								
								pkg/services/tmdb.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,515 @@ | |||||||
|  | package services | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 	"strconv" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type TMDBService struct { | ||||||
|  | 	accessToken string | ||||||
|  | 	baseURL     string | ||||||
|  | 	client      *http.Client | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewTMDBService(accessToken string) *TMDBService { | ||||||
|  | 	return &TMDBService{ | ||||||
|  | 		accessToken: accessToken, | ||||||
|  | 		baseURL:     "https://api.themoviedb.org/3", | ||||||
|  | 		client:      &http.Client{}, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) makeRequest(endpoint string, target interface{}) error { | ||||||
|  | 	req, err := http.NewRequest("GET", endpoint, nil) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Используем Bearer токен вместо API key в query параметрах | ||||||
|  | 	req.Header.Set("Authorization", "Bearer "+s.accessToken) | ||||||
|  | 	req.Header.Set("Accept", "application/json") | ||||||
|  |  | ||||||
|  | 	resp, err := s.client.Do(req) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	if resp.StatusCode != http.StatusOK { | ||||||
|  | 		return fmt.Errorf("TMDB API error: %d", resp.StatusCode) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return json.NewDecoder(resp.Body).Decode(target) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) SearchMovies(query string, page int, language, region string, year int) (*models.TMDBResponse, error) { | ||||||
|  | 	params := url.Values{} | ||||||
|  | 	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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) SearchMulti(query string, page int, language string) (*models.MultiSearchResponse, error) { | ||||||
|  | 	params := url.Values{} | ||||||
|  | 	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") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	endpoint := fmt.Sprintf("%s/search/multi?%s", s.baseURL, params.Encode()) | ||||||
|  |  | ||||||
|  | 	var response models.MultiSearchResponse | ||||||
|  | 	err := s.makeRequest(endpoint, &response) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Фильтруем результаты: убираем "person", и без названия | ||||||
|  | 	filteredResults := make([]models.MultiSearchResult, 0) | ||||||
|  | 	for _, result := range response.Results { | ||||||
|  | 		if result.MediaType == "person" { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		hasTitle := false | ||||||
|  | 		if result.MediaType == "movie" && result.Title != "" { | ||||||
|  | 			hasTitle = true | ||||||
|  | 		} else if result.MediaType == "tv" && result.Name != "" { | ||||||
|  | 			hasTitle = true | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if hasTitle { | ||||||
|  | 			filteredResults = append(filteredResults, result) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response.Results = filteredResults | ||||||
|  | 	response.TotalResults = len(filteredResults) | ||||||
|  |  | ||||||
|  | 	return &response, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Алиас для совместимости с новым WebTorrent handler | ||||||
|  | func (s *TMDBService) SearchTV(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) { | ||||||
|  | 	return s.SearchTVShows(query, page, language, firstAirDateYear) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) { | ||||||
|  | 	params := url.Values{} | ||||||
|  | 	params.Set("query", query) | ||||||
|  | 	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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) { | ||||||
|  | 	params := url.Values{} | ||||||
|  |  | ||||||
|  | 	if language != "" { | ||||||
|  | 		params.Set("language", language) | ||||||
|  | 	} else { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode()) | ||||||
|  |  | ||||||
|  | 	var movie models.Movie | ||||||
|  | 	err := s.makeRequest(endpoint, &movie) | ||||||
|  | 	return &movie, err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) { | ||||||
|  | 	params := url.Values{} | ||||||
|  |  | ||||||
|  | 	if language != "" { | ||||||
|  | 		params.Set("language", language) | ||||||
|  | 	} else { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode()) | ||||||
|  |  | ||||||
|  | 	var tvShow models.TVShow | ||||||
|  | 	err := s.makeRequest(endpoint, &tvShow) | ||||||
|  | 	return &tvShow, err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) { | ||||||
|  | 	params := url.Values{} | ||||||
|  |  | ||||||
|  | 	if language != "" { | ||||||
|  | 		params.Set("language", language) | ||||||
|  | 	} else { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) { | ||||||
|  | 	// Получаем жанры фильмов | ||||||
|  | 	movieGenres, err := s.GetGenres("movie", "ru-RU") | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Получаем жанры сериалов | ||||||
|  | 	tvGenres, err := s.GetGenres("tv", "ru-RU") | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Объединяем жанры, убирая дубликаты | ||||||
|  | 	allGenres := make(map[int]models.Genre) | ||||||
|  |  | ||||||
|  | 	for _, genre := range movieGenres.Genres { | ||||||
|  | 		allGenres[genre.ID] = genre | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for _, genre := range tvGenres.Genres { | ||||||
|  | 		allGenres[genre.ID] = genre | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Преобразуем обратно в слайс | ||||||
|  | 	var genres []models.Genre | ||||||
|  | 	for _, genre := range allGenres { | ||||||
|  | 		genres = append(genres, genre) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return &models.GenresResponse{Genres: genres}, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode()) | ||||||
|  |  | ||||||
|  | 	var response models.TMDBTVResponse | ||||||
|  | 	err := s.makeRequest(endpoint, &response) | ||||||
|  | 	return &response, err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode()) | ||||||
|  |  | ||||||
|  | 	var response models.TMDBTVResponse | ||||||
|  | 	err := s.makeRequest(endpoint, &response) | ||||||
|  | 	return &response, err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode()) | ||||||
|  |  | ||||||
|  | 	var response models.TMDBTVResponse | ||||||
|  | 	err := s.makeRequest(endpoint, &response) | ||||||
|  | 	return &response, err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) (*models.TMDBResponse, error) { | ||||||
|  | 	params := url.Values{} | ||||||
|  | 	params.Set("page", strconv.Itoa(page)) | ||||||
|  | 	params.Set("with_genres", strconv.Itoa(genreID)) | ||||||
|  | 	params.Set("sort_by", "popularity.desc") | ||||||
|  |  | ||||||
|  | 	if language != "" { | ||||||
|  | 		params.Set("language", language) | ||||||
|  | 	} else { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	endpoint := fmt.Sprintf("%s/discover/movie?%s", s.baseURL, params.Encode()) | ||||||
|  |  | ||||||
|  | 	var response models.TMDBResponse | ||||||
|  | 	err := s.makeRequest(endpoint, &response) | ||||||
|  | 	return &response, err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) DiscoverTVByGenre(genreID, page int, language string) (*models.TMDBResponse, error) { | ||||||
|  | 	params := url.Values{} | ||||||
|  | 	params.Set("page", strconv.Itoa(page)) | ||||||
|  | 	params.Set("with_genres", strconv.Itoa(genreID)) | ||||||
|  | 	params.Set("sort_by", "popularity.desc") | ||||||
|  |  | ||||||
|  | 	if language != "" { | ||||||
|  | 		params.Set("language", language) | ||||||
|  | 	} else { | ||||||
|  | 		params.Set("language", "ru-RU") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	endpoint := fmt.Sprintf("%s/discover/tv?%s", s.baseURL, params.Encode()) | ||||||
|  |  | ||||||
|  | 	var response models.TMDBResponse | ||||||
|  | 	err := s.makeRequest(endpoint, &response) | ||||||
|  | 	return &response, err | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TMDBService) GetTVSeason(tvID, seasonNumber int, language string) (*models.SeasonDetails, error) { | ||||||
|  | 	if language == "" { | ||||||
|  | 		language = "ru-RU" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	endpoint := fmt.Sprintf("%s/tv/%d/season/%d?language=%s", s.baseURL, tvID, seasonNumber, language) | ||||||
|  |  | ||||||
|  | 	var season models.SeasonDetails | ||||||
|  | 	err := s.makeRequest(endpoint, &season) | ||||||
|  | 	return &season, err | ||||||
|  | } | ||||||
							
								
								
									
										935
									
								
								pkg/services/torrent.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										935
									
								
								pkg/services/torrent.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,935 @@ | |||||||
|  | package services | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 	"regexp" | ||||||
|  | 	"sort" | ||||||
|  | 	"strconv" | ||||||
|  | 	"strings" | ||||||
|  | 	"time" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type TorrentService struct { | ||||||
|  | 	client  *http.Client | ||||||
|  | 	baseURL string | ||||||
|  | 	apiKey  string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewTorrentServiceWithConfig(baseURL, apiKey string) *TorrentService { | ||||||
|  | 	return &TorrentService{ | ||||||
|  | 		client:  &http.Client{Timeout: 8 * time.Second}, | ||||||
|  | 		baseURL: baseURL, | ||||||
|  | 		apiKey:  apiKey, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewTorrentService() *TorrentService { | ||||||
|  | 	return &TorrentService{ | ||||||
|  | 		client:  &http.Client{Timeout: 8 * time.Second}, | ||||||
|  | 		baseURL: "http://redapi.cfhttp.top", | ||||||
|  | 		apiKey:  "", | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchTorrents - основной метод поиска торрентов через RedAPI | ||||||
|  | func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) { | ||||||
|  | 	searchParams := url.Values{} | ||||||
|  |  | ||||||
|  | 	for key, value := range params { | ||||||
|  | 		if value != "" { | ||||||
|  | 			if key == "category" { | ||||||
|  | 				searchParams.Add("category[]", value) | ||||||
|  | 			} else { | ||||||
|  | 				searchParams.Add(key, value) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if s.apiKey != "" { | ||||||
|  | 		searchParams.Add("apikey", s.apiKey) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	searchURL := fmt.Sprintf("%s/api/v2.0/indexers/all/results?%s", s.baseURL, searchParams.Encode()) | ||||||
|  |  | ||||||
|  | 	resp, err := s.client.Get(searchURL) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to search torrents: %w", err) | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	body, err := io.ReadAll(resp.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to read response: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var redAPIResponse models.RedAPIResponse | ||||||
|  | 	if err := json.Unmarshal(body, &redAPIResponse); err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to parse response: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	results := s.parseRedAPIResults(redAPIResponse) | ||||||
|  |  | ||||||
|  | 	return &models.TorrentSearchResponse{ | ||||||
|  | 		Query:   params["query"], | ||||||
|  | 		Results: results, | ||||||
|  | 		Total:   len(results), | ||||||
|  | 	}, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // parseRedAPIResults преобразует результаты RedAPI в наш формат | ||||||
|  | func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models.TorrentResult { | ||||||
|  | 	var results []models.TorrentResult | ||||||
|  |  | ||||||
|  | 	for _, torrent := range data.Results { | ||||||
|  | 		var sizeStr string | ||||||
|  | 		switch v := torrent.Size.(type) { | ||||||
|  | 		case string: | ||||||
|  | 			sizeStr = v | ||||||
|  | 		case float64: | ||||||
|  | 			sizeStr = fmt.Sprintf("%.0f", v) | ||||||
|  | 		case int: | ||||||
|  | 			sizeStr = fmt.Sprintf("%d", v) | ||||||
|  | 		default: | ||||||
|  | 			sizeStr = "" | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		result := models.TorrentResult{ | ||||||
|  | 			Title:       torrent.Title, | ||||||
|  | 			Tracker:     torrent.Tracker, | ||||||
|  | 			Size:        sizeStr, | ||||||
|  | 			Seeders:     torrent.Seeders, | ||||||
|  | 			Peers:       torrent.Peers, | ||||||
|  | 			MagnetLink:  torrent.MagnetUri, | ||||||
|  | 			PublishDate: torrent.PublishDate, | ||||||
|  | 			Category:    torrent.CategoryDesc, | ||||||
|  | 			Details:     torrent.Details, | ||||||
|  | 			Source:      "RedAPI", | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if torrent.Info != nil { | ||||||
|  | 			switch v := torrent.Info.Quality.(type) { | ||||||
|  | 			case string: | ||||||
|  | 				result.Quality = v | ||||||
|  | 			case float64: | ||||||
|  | 				result.Quality = fmt.Sprintf("%.0fp", v) | ||||||
|  | 			case int: | ||||||
|  | 				result.Quality = fmt.Sprintf("%dp", v) | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			result.Voice = torrent.Info.Voices | ||||||
|  | 			result.Types = torrent.Info.Types | ||||||
|  | 			result.Seasons = torrent.Info.Seasons | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if result.Quality == "" { | ||||||
|  | 			result.Quality = s.ExtractQuality(result.Title) | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		results = append(results, result) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return results | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций | ||||||
|  | func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) { | ||||||
|  | 	title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, fmt.Errorf("failed to get title from TMDB: %w", err) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	params := map[string]string{ | ||||||
|  | 		"imdb":           imdbID, | ||||||
|  | 		"query":          title, | ||||||
|  | 		"title_original": originalTitle, | ||||||
|  | 		"year":           year, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	switch mediaType { | ||||||
|  | 	case "movie": | ||||||
|  | 		params["is_serial"] = "1" | ||||||
|  | 		params["category"] = "2000" | ||||||
|  | 	case "serial", "series", "tv": | ||||||
|  | 		params["is_serial"] = "2" | ||||||
|  | 		params["category"] = "5000" | ||||||
|  | 	case "anime": | ||||||
|  | 		params["is_serial"] = "5" | ||||||
|  | 		params["category"] = "5070" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if options != nil && options.Season != nil && *options.Season > 0 { | ||||||
|  | 		params["season"] = strconv.Itoa(*options.Season) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response, err := s.SearchTorrents(params) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if options != nil { | ||||||
|  | 		response.Results = s.FilterByContentType(response.Results, options.ContentType) | ||||||
|  | 		response.Results = s.FilterTorrents(response.Results, options) | ||||||
|  | 		response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder) | ||||||
|  | 	} | ||||||
|  | 	response.Total = len(response.Results) | ||||||
|  |  | ||||||
|  | 	if len(response.Results) < 5 && (mediaType == "serial" || mediaType == "series" || mediaType == "tv") && options != nil && options.Season != nil { | ||||||
|  | 		paramsNoSeason := map[string]string{ | ||||||
|  | 			"imdb":           imdbID, | ||||||
|  | 			"query":          title, | ||||||
|  | 			"title_original": originalTitle, | ||||||
|  | 			"year":           year, | ||||||
|  | 			"is_serial":      "2", | ||||||
|  | 			"category":       "5000", | ||||||
|  | 		} | ||||||
|  | 		fallbackResp, err := s.SearchTorrents(paramsNoSeason) | ||||||
|  | 		if err == nil { | ||||||
|  | 			filtered := s.filterBySeason(fallbackResp.Results, *options.Season) | ||||||
|  | 			all := append(response.Results, filtered...) | ||||||
|  | 			unique := make([]models.TorrentResult, 0, len(all)) | ||||||
|  | 			seen := make(map[string]bool) | ||||||
|  | 			for _, t := range all { | ||||||
|  | 				if !seen[t.MagnetLink] { | ||||||
|  | 					unique = append(unique, t) | ||||||
|  | 					seen[t.MagnetLink] = true | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			response.Results = unique | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return response, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchMovies - поиск фильмов с дополнительной фильтрацией | ||||||
|  | func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) { | ||||||
|  | 	params := map[string]string{ | ||||||
|  | 		"title":          title, | ||||||
|  | 		"title_original": originalTitle, | ||||||
|  | 		"year":           year, | ||||||
|  | 		"is_serial":      "1", | ||||||
|  | 		"category":       "2000", | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response, err := s.SearchTorrents(params) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response.Results = s.FilterByContentType(response.Results, "movie") | ||||||
|  | 	response.Total = len(response.Results) | ||||||
|  |  | ||||||
|  | 	return response, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchSeries - поиск сериалов с поддержкой fallback и фильтрации по сезону | ||||||
|  | func (s *TorrentService) SearchSeries(title, originalTitle, year string, season *int) (*models.TorrentSearchResponse, error) { | ||||||
|  | 	params := map[string]string{ | ||||||
|  | 		"title":          title, | ||||||
|  | 		"title_original": originalTitle, | ||||||
|  | 		"year":           year, | ||||||
|  | 		"is_serial":      "2", | ||||||
|  | 		"category":       "5000", | ||||||
|  | 	} | ||||||
|  | 	if season != nil { | ||||||
|  | 		params["season"] = strconv.Itoa(*season) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response, err := s.SearchTorrents(params) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Если указан сезон и результатов мало, делаем fallback-поиск без сезона и фильтруем на клиенте | ||||||
|  | 	if season != nil && len(response.Results) < 5 { | ||||||
|  | 		paramsNoSeason := map[string]string{ | ||||||
|  | 			"title":          title, | ||||||
|  | 			"title_original": originalTitle, | ||||||
|  | 			"year":           year, | ||||||
|  | 			"is_serial":      "2", | ||||||
|  | 			"category":       "5000", | ||||||
|  | 		} | ||||||
|  | 		fallbackResp, err := s.SearchTorrents(paramsNoSeason) | ||||||
|  | 		if err == nil { | ||||||
|  | 			filtered := s.filterBySeason(fallbackResp.Results, *season) | ||||||
|  | 			// Объединяем и убираем дубликаты по MagnetLink | ||||||
|  | 			all := append(response.Results, filtered...) | ||||||
|  | 			unique := make([]models.TorrentResult, 0, len(all)) | ||||||
|  | 			seen := make(map[string]bool) | ||||||
|  | 			for _, t := range all { | ||||||
|  | 				if !seen[t.MagnetLink] { | ||||||
|  | 					unique = append(unique, t) | ||||||
|  | 					seen[t.MagnetLink] = true | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			response.Results = unique | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response.Results = s.FilterByContentType(response.Results, "serial") | ||||||
|  | 	response.Total = len(response.Results) | ||||||
|  | 	return response, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // filterBySeason - фильтрация результатов по сезону (аналогично JS) | ||||||
|  | func (s *TorrentService) filterBySeason(results []models.TorrentResult, season int) []models.TorrentResult { | ||||||
|  | 	if season == 0 { | ||||||
|  | 		return results | ||||||
|  | 	} | ||||||
|  | 	filtered := make([]models.TorrentResult, 0, len(results)) | ||||||
|  | 	seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`) | ||||||
|  | 	for _, torrent := range results { | ||||||
|  | 		found := false | ||||||
|  | 		// Проверяем поле seasons | ||||||
|  | 		for _, s := range torrent.Seasons { | ||||||
|  | 			if s == season { | ||||||
|  | 				found = true | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		if found { | ||||||
|  | 			filtered = append(filtered, torrent) | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 		// Проверяем в названии | ||||||
|  | 		matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1) | ||||||
|  | 		for _, match := range matches { | ||||||
|  | 			seasonNumber := 0 | ||||||
|  | 			if match[1] != "" { | ||||||
|  | 				seasonNumber, _ = strconv.Atoi(match[1]) | ||||||
|  | 			} else if match[2] != "" { | ||||||
|  | 				seasonNumber, _ = strconv.Atoi(match[2]) | ||||||
|  | 			} | ||||||
|  | 			if seasonNumber == season { | ||||||
|  | 				filtered = append(filtered, torrent) | ||||||
|  | 				break | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return filtered | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchAnime - поиск аниме | ||||||
|  | func (s *TorrentService) SearchAnime(title, originalTitle, year string) (*models.TorrentSearchResponse, error) { | ||||||
|  | 	params := map[string]string{ | ||||||
|  | 		"title":          title, | ||||||
|  | 		"title_original": originalTitle, | ||||||
|  | 		"year":           year, | ||||||
|  | 		"is_serial":      "5", | ||||||
|  | 		"category":       "5070", | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response, err := s.SearchTorrents(params) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	response.Results = s.FilterByContentType(response.Results, "anime") | ||||||
|  | 	response.Total = len(response.Results) | ||||||
|  |  | ||||||
|  | 	return response, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // AllohaResponse - структура ответа от Alloha API | ||||||
|  | type AllohaResponse struct { | ||||||
|  | 	Status string `json:"status"` | ||||||
|  | 	Data   struct { | ||||||
|  | 		Name         string `json:"name"` | ||||||
|  | 		OriginalName string `json:"original_name"` | ||||||
|  | 		Year         int    `json:"year"` | ||||||
|  | 		Category     int    `json:"category"` // 1-фильм, 2-сериал | ||||||
|  | 	} `json:"data"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // getMovieInfoByIMDB - получение информации через Alloha API (как в JavaScript версии) | ||||||
|  | func (s *TorrentService) getMovieInfoByIMDB(imdbID string) (string, string, string, error) { | ||||||
|  | 	// Используем тот же токен что и в JavaScript версии | ||||||
|  | 	endpoint := fmt.Sprintf("https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&imdb=%s", imdbID) | ||||||
|  |  | ||||||
|  | 	req, err := http.NewRequest("GET", endpoint, nil) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", "", "", err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	resp, err := s.client.Do(req) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", "", "", err | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	body, err := io.ReadAll(resp.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", "", "", err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var allohaResponse AllohaResponse | ||||||
|  | 	if err := json.Unmarshal(body, &allohaResponse); err != nil { | ||||||
|  | 		return "", "", "", err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if allohaResponse.Status != "success" { | ||||||
|  | 		return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	title := allohaResponse.Data.Name | ||||||
|  | 	originalTitle := allohaResponse.Data.OriginalName | ||||||
|  | 	year := "" | ||||||
|  | 	if allohaResponse.Data.Year > 0 { | ||||||
|  | 		year = strconv.Itoa(allohaResponse.Data.Year) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return title, originalTitle, year, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // getTitleFromTMDB - получение информации из TMDB (с fallback на Alloha API) | ||||||
|  | func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, mediaType string) (string, string, string, error) { | ||||||
|  | 	// Сначала пробуем Alloha API (как в JavaScript версии) | ||||||
|  | 	title, originalTitle, year, err := s.getMovieInfoByIMDB(imdbID) | ||||||
|  | 	if err == nil { | ||||||
|  | 		return title, originalTitle, year, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Если Alloha API не работает, пробуем TMDB API | ||||||
|  | 	endpoint := fmt.Sprintf("https://api.themoviedb.org/3/find/%s", imdbID) | ||||||
|  |  | ||||||
|  | 	req, err := http.NewRequest("GET", endpoint, nil) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", "", "", err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	params := url.Values{} | ||||||
|  | 	params.Set("external_source", "imdb_id") | ||||||
|  | 	params.Set("language", "ru-RU") | ||||||
|  | 	req.URL.RawQuery = params.Encode() | ||||||
|  |  | ||||||
|  | 	req.Header.Set("Authorization", "Bearer "+tmdbService.accessToken) | ||||||
|  | 	req.Header.Set("Accept", "application/json") | ||||||
|  |  | ||||||
|  | 	resp, err := s.client.Do(req) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", "", "", err | ||||||
|  | 	} | ||||||
|  | 	defer resp.Body.Close() | ||||||
|  |  | ||||||
|  | 	body, err := io.ReadAll(resp.Body) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", "", "", err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var findResponse struct { | ||||||
|  | 		MovieResults []struct { | ||||||
|  | 			Title         string `json:"title"` | ||||||
|  | 			OriginalTitle string `json:"original_title"` | ||||||
|  | 			ReleaseDate   string `json:"release_date"` | ||||||
|  | 		} `json:"movie_results"` | ||||||
|  | 		TVResults []struct { | ||||||
|  | 			Name         string `json:"name"` | ||||||
|  | 			OriginalName string `json:"original_name"` | ||||||
|  | 			FirstAirDate string `json:"first_air_date"` | ||||||
|  | 		} `json:"tv_results"` | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if err := json.Unmarshal(body, &findResponse); err != nil { | ||||||
|  | 		return "", "", "", err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if mediaType == "movie" && len(findResponse.MovieResults) > 0 { | ||||||
|  | 		movie := findResponse.MovieResults[0] | ||||||
|  | 		title := movie.Title | ||||||
|  | 		originalTitle := movie.OriginalTitle | ||||||
|  | 		year := "" | ||||||
|  | 		if movie.ReleaseDate != "" { | ||||||
|  | 			year = movie.ReleaseDate[:4] | ||||||
|  | 		} | ||||||
|  | 		return title, originalTitle, year, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	if (mediaType == "tv" || mediaType == "series") && len(findResponse.TVResults) > 0 { | ||||||
|  | 		tv := findResponse.TVResults[0] | ||||||
|  | 		title := tv.Name | ||||||
|  | 		originalTitle := tv.OriginalName | ||||||
|  | 		year := "" | ||||||
|  | 		if tv.FirstAirDate != "" { | ||||||
|  | 			year = tv.FirstAirDate[:4] | ||||||
|  | 		} | ||||||
|  | 		return title, originalTitle, year, nil | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // FilterByContentType - фильтрация по типу контента (как в JS) | ||||||
|  | func (s *TorrentService) FilterByContentType(results []models.TorrentResult, contentType string) []models.TorrentResult { | ||||||
|  | 	if contentType == "" { | ||||||
|  | 		return results | ||||||
|  | 	} | ||||||
|  | 	var filtered []models.TorrentResult | ||||||
|  | 	for _, torrent := range results { | ||||||
|  | 		// Фильтрация по полю types, если оно есть | ||||||
|  | 		if len(torrent.Types) > 0 { | ||||||
|  | 			switch contentType { | ||||||
|  | 			case "movie": | ||||||
|  | 				if s.containsAny(torrent.Types, []string{"movie", "multfilm", "documovie"}) { | ||||||
|  | 					filtered = append(filtered, torrent) | ||||||
|  | 				} | ||||||
|  | 			case "serial", "series", "tv": | ||||||
|  | 				if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) { | ||||||
|  | 					filtered = append(filtered, torrent) | ||||||
|  | 				} | ||||||
|  | 			case "anime": | ||||||
|  | 				if s.contains(torrent.Types, "anime") { | ||||||
|  | 					filtered = append(filtered, torrent) | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 		// Фильтрация по названию, если types недоступно | ||||||
|  | 		title := strings.ToLower(torrent.Title) | ||||||
|  | 		switch contentType { | ||||||
|  | 		case "movie": | ||||||
|  | 			if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) { | ||||||
|  | 				filtered = append(filtered, torrent) | ||||||
|  | 			} | ||||||
|  | 		case "serial", "series", "tv": | ||||||
|  | 			if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) { | ||||||
|  | 				filtered = append(filtered, torrent) | ||||||
|  | 			} | ||||||
|  | 		case "anime": | ||||||
|  | 			if torrent.Category == "TV/Anime" || regexp.MustCompile(`(?i)anime`).MatchString(title) { | ||||||
|  | 				filtered = append(filtered, torrent) | ||||||
|  | 			} | ||||||
|  | 		default: | ||||||
|  | 			filtered = append(filtered, torrent) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return filtered | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // FilterTorrents - фильтрация торрентов по опциям | ||||||
|  | func (s *TorrentService) FilterTorrents(torrents []models.TorrentResult, options *models.TorrentSearchOptions) []models.TorrentResult { | ||||||
|  | 	if options == nil { | ||||||
|  | 		return torrents | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var filtered []models.TorrentResult | ||||||
|  |  | ||||||
|  | 	for _, torrent := range torrents { | ||||||
|  | 		// Фильтрация по качеству | ||||||
|  | 		if len(options.Quality) > 0 { | ||||||
|  | 			found := false | ||||||
|  | 			for _, quality := range options.Quality { | ||||||
|  | 				if strings.EqualFold(torrent.Quality, quality) { | ||||||
|  | 					found = true | ||||||
|  | 					break | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			if !found { | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Фильтрация по минимальному качеству | ||||||
|  | 		if options.MinQuality != "" && !s.qualityMeetsMinimum(torrent.Quality, options.MinQuality) { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Фильтрация по максимальному качеству | ||||||
|  | 		if options.MaxQuality != "" && !s.qualityMeetsMaximum(torrent.Quality, options.MaxQuality) { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Исключение качеств | ||||||
|  | 		if len(options.ExcludeQualities) > 0 { | ||||||
|  | 			excluded := false | ||||||
|  | 			for _, excludeQuality := range options.ExcludeQualities { | ||||||
|  | 				if strings.EqualFold(torrent.Quality, excludeQuality) { | ||||||
|  | 					excluded = true | ||||||
|  | 					break | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			if excluded { | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Фильтрация по HDR | ||||||
|  | 		if options.HDR != nil { | ||||||
|  | 			hasHDR := regexp.MustCompile(`(?i)(hdr|dolby.vision|dv)`).MatchString(torrent.Title) | ||||||
|  | 			if *options.HDR != hasHDR { | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Фильтрация по HEVC | ||||||
|  | 		if options.HEVC != nil { | ||||||
|  | 			hasHEVC := regexp.MustCompile(`(?i)(hevc|h\.265|x265)`).MatchString(torrent.Title) | ||||||
|  | 			if *options.HEVC != hasHEVC { | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Фильтрация по сезону (дополнительная на клиенте) | ||||||
|  | 		if options.Season != nil { | ||||||
|  | 			if !s.matchesSeason(torrent, *options.Season) { | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		filtered = append(filtered, torrent) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return filtered | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // matchesSeason - проверка соответствия сезону | ||||||
|  | func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int) bool { | ||||||
|  | 	// Проверяем в поле seasons | ||||||
|  | 	for _, s := range torrent.Seasons { | ||||||
|  | 		if s == season { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Проверяем в названии | ||||||
|  | 	seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`) | ||||||
|  | 	matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1) | ||||||
|  | 	for _, match := range matches { | ||||||
|  | 		seasonNumber := 0 | ||||||
|  | 		if match[1] != "" { | ||||||
|  | 			seasonNumber, _ = strconv.Atoi(match[1]) | ||||||
|  | 		} else if match[2] != "" { | ||||||
|  | 			seasonNumber, _ = strconv.Atoi(match[2]) | ||||||
|  | 		} | ||||||
|  | 		if seasonNumber == season { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return false | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ExtractQuality - извлечение качества из названия | ||||||
|  | func (s *TorrentService) ExtractQuality(title string) string { | ||||||
|  | 	title = strings.ToUpper(title) | ||||||
|  |  | ||||||
|  | 	qualityPatterns := []struct { | ||||||
|  | 		pattern string | ||||||
|  | 		quality string | ||||||
|  | 	}{ | ||||||
|  | 		{`2160P|4K`, "2160p"}, | ||||||
|  | 		{`1440P`, "1440p"}, | ||||||
|  | 		{`1080P`, "1080p"}, | ||||||
|  | 		{`720P`, "720p"}, | ||||||
|  | 		{`480P`, "480p"}, | ||||||
|  | 		{`360P`, "360p"}, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	for _, qp := range qualityPatterns { | ||||||
|  | 		if matched, _ := regexp.MatchString(qp.pattern, title); matched { | ||||||
|  | 			if qp.quality == "2160p" { | ||||||
|  | 				return "4K" | ||||||
|  | 			} | ||||||
|  | 			return qp.quality | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return "Unknown" | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // sortTorrents - сортировка результатов | ||||||
|  | func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, sortOrder string) []models.TorrentResult { | ||||||
|  | 	if sortBy == "" { | ||||||
|  | 		sortBy = "seeders" | ||||||
|  | 	} | ||||||
|  | 	if sortOrder == "" { | ||||||
|  | 		sortOrder = "desc" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	sort.Slice(torrents, func(i, j int) bool { | ||||||
|  | 		var less bool | ||||||
|  |  | ||||||
|  | 		switch sortBy { | ||||||
|  | 		case "seeders": | ||||||
|  | 			less = torrents[i].Seeders < torrents[j].Seeders | ||||||
|  | 		case "size": | ||||||
|  | 			less = s.compareSizes(torrents[i].Size, torrents[j].Size) | ||||||
|  | 		case "date": | ||||||
|  | 			t1, _ := time.Parse(time.RFC3339, torrents[i].PublishDate) | ||||||
|  | 			t2, _ := time.Parse(time.RFC3339, torrents[j].PublishDate) | ||||||
|  | 			less = t1.Before(t2) | ||||||
|  | 		default: | ||||||
|  | 			less = torrents[i].Seeders < torrents[j].Seeders | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if sortOrder == "asc" { | ||||||
|  | 			return less | ||||||
|  | 		} | ||||||
|  | 		return !less | ||||||
|  | 	}) | ||||||
|  |  | ||||||
|  | 	return torrents | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GroupByQuality - группировка по качеству | ||||||
|  | func (s *TorrentService) GroupByQuality(results []models.TorrentResult) map[string][]models.TorrentResult { | ||||||
|  | 	groups := make(map[string][]models.TorrentResult) | ||||||
|  |  | ||||||
|  | 	for _, torrent := range results { | ||||||
|  | 		quality := torrent.Quality | ||||||
|  | 		if quality == "" { | ||||||
|  | 			quality = "unknown" | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Объединяем 4K и 2160p в одну группу | ||||||
|  | 		if quality == "2160p" { | ||||||
|  | 			quality = "4K" | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		groups[quality] = append(groups[quality], torrent) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Сортируем торренты внутри каждой группы по сидам | ||||||
|  | 	for quality := range groups { | ||||||
|  | 		sort.Slice(groups[quality], func(i, j int) bool { | ||||||
|  | 			return groups[quality][i].Seeders > groups[quality][j].Seeders | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return groups | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GroupBySeason - группировка по сезонам | ||||||
|  | func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[string][]models.TorrentResult { | ||||||
|  | 	groups := make(map[string][]models.TorrentResult) | ||||||
|  |  | ||||||
|  | 	for _, torrent := range results { | ||||||
|  | 		seasons := make(map[int]bool) | ||||||
|  |  | ||||||
|  | 		// Извлекаем сезоны из поля seasons | ||||||
|  | 		for _, season := range torrent.Seasons { | ||||||
|  | 			seasons[season] = true | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Извлекаем сезоны из названия | ||||||
|  | 		seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`) | ||||||
|  | 		matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1) | ||||||
|  | 		for _, match := range matches { | ||||||
|  | 			seasonNumber := 0 | ||||||
|  | 			if match[1] != "" { | ||||||
|  | 				seasonNumber, _ = strconv.Atoi(match[1]) | ||||||
|  | 			} else if match[2] != "" { | ||||||
|  | 				seasonNumber, _ = strconv.Atoi(match[2]) | ||||||
|  | 			} | ||||||
|  | 			if seasonNumber > 0 { | ||||||
|  | 				seasons[seasonNumber] = true | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Если сезоны не найдены, добавляем в группу "unknown" | ||||||
|  | 		if len(seasons) == 0 { | ||||||
|  | 			groups["Неизвестно"] = append(groups["Неизвестно"], torrent) | ||||||
|  | 		} else { | ||||||
|  | 			// Добавляем торрент во все соответствующие группы сезонов | ||||||
|  | 			for season := range seasons { | ||||||
|  | 				seasonKey := fmt.Sprintf("Сезон %d", season) | ||||||
|  | 				// Проверяем дубликаты | ||||||
|  | 				found := false | ||||||
|  | 				for _, existing := range groups[seasonKey] { | ||||||
|  | 					if existing.MagnetLink == torrent.MagnetLink { | ||||||
|  | 						found = true | ||||||
|  | 						break | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  | 				if !found { | ||||||
|  | 					groups[seasonKey] = append(groups[seasonKey], torrent) | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Сортируем торренты внутри каждой группы по сидам | ||||||
|  | 	for season := range groups { | ||||||
|  | 		sort.Slice(groups[season], func(i, j int) bool { | ||||||
|  | 			return groups[season][i].Seeders > groups[season][j].Seeders | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return groups | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // GetAvailableSeasons - получение доступных сезонов для сериала | ||||||
|  | func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string) ([]int, error) { | ||||||
|  | 	response, err := s.SearchSeries(title, originalTitle, year, nil) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	seasonsSet := make(map[int]bool) | ||||||
|  |  | ||||||
|  | 	for _, torrent := range response.Results { | ||||||
|  | 		// Извлекаем из поля seasons | ||||||
|  | 		for _, season := range torrent.Seasons { | ||||||
|  | 			seasonsSet[season] = true | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// Извлекаем из названия | ||||||
|  | 		seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`) | ||||||
|  | 		matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1) | ||||||
|  | 		for _, match := range matches { | ||||||
|  | 			seasonNumber := 0 | ||||||
|  | 			if match[1] != "" { | ||||||
|  | 				seasonNumber, _ = strconv.Atoi(match[1]) | ||||||
|  | 			} else if match[2] != "" { | ||||||
|  | 				seasonNumber, _ = strconv.Atoi(match[2]) | ||||||
|  | 			} | ||||||
|  | 			if seasonNumber > 0 { | ||||||
|  | 				seasonsSet[seasonNumber] = true | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	var seasons []int | ||||||
|  | 	for season := range seasonsSet { | ||||||
|  | 		seasons = append(seasons, season) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	sort.Ints(seasons) | ||||||
|  | 	return seasons, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // SearchByImdb - поиск по IMDB ID (movie/serial/anime). | ||||||
|  | func (s *TorrentService) SearchByImdb(imdbID, contentType string, season *int) ([]models.TorrentResult, error) { | ||||||
|  | 	if imdbID == "" || !strings.HasPrefix(imdbID, "tt") { | ||||||
|  | 		return nil, fmt.Errorf("Неверный формат IMDB ID. Должен быть в формате tt1234567") | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// НЕ добавляем title, originalTitle, year, чтобы запрос не был слишком строгим. | ||||||
|  | 	params := map[string]string{ | ||||||
|  | 		"imdb": imdbID, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Определяем тип контента для API | ||||||
|  | 	switch contentType { | ||||||
|  | 	case "movie": | ||||||
|  | 		params["is_serial"] = "1" | ||||||
|  | 		params["category"] = "2000" | ||||||
|  | 	case "serial", "series", "tv": | ||||||
|  | 		params["is_serial"] = "2" | ||||||
|  | 		params["category"] = "5000" | ||||||
|  | 	case "anime": | ||||||
|  | 		params["is_serial"] = "5" | ||||||
|  | 		params["category"] = "5070" | ||||||
|  | 	default: | ||||||
|  | 		// Значение по умолчанию на случай неизвестного типа | ||||||
|  | 		params["is_serial"] = "1" | ||||||
|  | 		params["category"] = "2000" | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Параметр season можно оставить, он полезен | ||||||
|  | 	if season != nil && *season > 0 { | ||||||
|  | 		params["season"] = strconv.Itoa(*season) | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	resp, err := s.SearchTorrents(params) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 	results := resp.Results | ||||||
|  |  | ||||||
|  | 	// Fallback для сериалов: если указан сезон и результатов мало, ищем без сезона и фильтруем на клиенте | ||||||
|  | 	if s.contains([]string{"serial", "series", "tv"}, contentType) && season != nil && len(results) < 5 { | ||||||
|  | 		paramsNoSeason := map[string]string{ | ||||||
|  | 			"imdb":      imdbID, | ||||||
|  | 			"is_serial": "2", | ||||||
|  | 			"category":  "5000", | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		fallbackResp, err := s.SearchTorrents(paramsNoSeason) | ||||||
|  | 		if err == nil { | ||||||
|  | 			filtered := s.filterBySeason(fallbackResp.Results, *season) | ||||||
|  | 			// Объединяем и убираем дубликаты по MagnetLink | ||||||
|  | 			all := append(results, filtered...) | ||||||
|  | 			unique := make([]models.TorrentResult, 0, len(all)) | ||||||
|  | 			seen := make(map[string]bool) | ||||||
|  | 			for _, t := range all { | ||||||
|  | 				if !seen[t.MagnetLink] { | ||||||
|  | 					unique = append(unique, t) | ||||||
|  | 					seen[t.MagnetLink] = true | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			results = unique | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Финальная фильтрация по типу контента на стороне клиента для надежности | ||||||
|  | 	results = s.FilterByContentType(results, contentType) | ||||||
|  | 	return results, nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ############# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ############# | ||||||
|  |  | ||||||
|  | func (s *TorrentService) qualityMeetsMinimum(quality, minQuality string) bool { | ||||||
|  | 	qualityOrder := map[string]int{ | ||||||
|  | 		"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	currentLevel, ok1 := qualityOrder[strings.ToLower(quality)] | ||||||
|  | 	minLevel, ok2 := qualityOrder[strings.ToLower(minQuality)] | ||||||
|  |  | ||||||
|  | 	if !ok1 || !ok2 { | ||||||
|  | 		return true // Если качество не определено, не фильтруем | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return currentLevel >= minLevel | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TorrentService) qualityMeetsMaximum(quality, maxQuality string) bool { | ||||||
|  | 	qualityOrder := map[string]int{ | ||||||
|  | 		"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6, | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	currentLevel, ok1 := qualityOrder[strings.ToLower(quality)] | ||||||
|  | 	maxLevel, ok2 := qualityOrder[strings.ToLower(maxQuality)] | ||||||
|  |  | ||||||
|  | 	if !ok1 || !ok2 { | ||||||
|  | 		return true // Если качество не определено, не фильтруем | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return currentLevel <= maxLevel | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TorrentService) parseSize(sizeStr string) int64 { | ||||||
|  | 	val, err := strconv.ParseInt(sizeStr, 10, 64) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return 0 | ||||||
|  | 	} | ||||||
|  | 	return val | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TorrentService) compareSizes(size1, size2 string) bool { | ||||||
|  | 	return s.parseSize(size1) < s.parseSize(size2) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TorrentService) contains(slice []string, item string) bool { | ||||||
|  | 	for _, s := range slice { | ||||||
|  | 		if strings.EqualFold(s, item) { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return false | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TorrentService) containsAny(slice []string, items []string) bool { | ||||||
|  | 	for _, item := range items { | ||||||
|  | 		if s.contains(slice, item) { | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return false | ||||||
|  | } | ||||||
							
								
								
									
										55
									
								
								pkg/services/tv.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								pkg/services/tv.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | package services | ||||||
|  |  | ||||||
|  | import ( | ||||||
|  | 	"go.mongodb.org/mongo-driver/mongo" | ||||||
|  |  | ||||||
|  | 	"neomovies-api/pkg/models" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | type TVService struct { | ||||||
|  | 	db   *mongo.Database | ||||||
|  | 	tmdb *TMDBService | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func NewTVService(db *mongo.Database, tmdb *TMDBService) *TVService { | ||||||
|  | 	return &TVService{ | ||||||
|  | 		db:   db, | ||||||
|  | 		tmdb: tmdb, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TVService) Search(query string, page int, language string, year int) (*models.TMDBTVResponse, error) { | ||||||
|  | 	return s.tmdb.SearchTVShows(query, page, language, year) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TVService) GetByID(id int, language string) (*models.TVShow, error) { | ||||||
|  | 	return s.tmdb.GetTVShow(id, language) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TVService) GetPopular(page int, language string) (*models.TMDBTVResponse, error) { | ||||||
|  | 	return s.tmdb.GetPopularTVShows(page, language) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TVService) GetTopRated(page int, language string) (*models.TMDBTVResponse, error) { | ||||||
|  | 	return s.tmdb.GetTopRatedTVShows(page, language) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TVService) GetOnTheAir(page int, language string) (*models.TMDBTVResponse, error) { | ||||||
|  | 	return s.tmdb.GetOnTheAirTVShows(page, language) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TVService) GetAiringToday(page int, language string) (*models.TMDBTVResponse, error) { | ||||||
|  | 	return s.tmdb.GetAiringTodayTVShows(page, language) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TVService) GetRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) { | ||||||
|  | 	return s.tmdb.GetTVRecommendations(id, page, language) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVResponse, error) { | ||||||
|  | 	return s.tmdb.GetSimilarTVShows(id, page, language) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) { | ||||||
|  | 	return s.tmdb.GetTVExternalIDs(id) | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								render.yaml
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								render.yaml
									
									
									
									
									
								
							| @@ -1,13 +0,0 @@ | |||||||
| services: |  | ||||||
|   - type: web |  | ||||||
|     name: neomovies-api |  | ||||||
|     env: go |  | ||||||
|     buildCommand: go build -o app |  | ||||||
|     startCommand: ./app |  | ||||||
|     envVars: |  | ||||||
|       - key: GIN_MODE |  | ||||||
|         value: release |  | ||||||
|       - key: TMDB_ACCESS_TOKEN |  | ||||||
|         sync: false |  | ||||||
|     healthCheckPath: /health |  | ||||||
|     autoDeploy: true |  | ||||||
							
								
								
									
										7
									
								
								run.sh
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								run.sh
									
									
									
									
									
								
							| @@ -1,7 +0,0 @@ | |||||||
| #!/bin/bash |  | ||||||
|  |  | ||||||
| # Переходим в директорию с приложением |  | ||||||
| cd "$HOME/neomovies-api" |  | ||||||
|  |  | ||||||
| # Запускаем приложение |  | ||||||
| PORT=$PORT GIN_MODE=release ./app |  | ||||||
							
								
								
									
										21
									
								
								vercel.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								vercel.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | { | ||||||
|  |   "version": 2, | ||||||
|  |   "builds": [ | ||||||
|  |     { | ||||||
|  |       "src": "api/index.go", | ||||||
|  |       "use": "@vercel/go", | ||||||
|  |       "config": { | ||||||
|  |         "maxDuration": 10 | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "routes": [ | ||||||
|  |     { | ||||||
|  |       "src": "/(.*)", | ||||||
|  |       "dest": "/api/index.go" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "env": { | ||||||
|  |     "GO_VERSION": "1.21" | ||||||
|  |   } | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user