mirror of
https://gitlab.com/foxixus/neomovies-api.git
synced 2025-10-27 17:38:51 +05:00
Compare commits
113 Commits
main
...
c7aa844f49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7aa844f49 | ||
|
|
0fbf0f0f42 | ||
|
|
f2f06485fd | ||
| f5a754ddf7 | |||
| be849fd103 | |||
| af625c7950 | |||
| 7631e34f2d | |||
| 18421848c2 | |||
| fd8e2cccfe | |||
| 30c48fbc50 | |||
| 36389f674f | |||
| 35ceb00217 | |||
| 7b8d92af14 | |||
| e5e70a635b | |||
| 28555a83e1 | |||
|
|
567b287322 | ||
|
|
03091b0fc3 | ||
|
|
42d38ba0d1 | ||
|
|
859a7fd380 | ||
|
|
303079740f | ||
|
|
39c8366ae1 | ||
|
|
d47b4fd0a8 | ||
|
|
0d54aacc7d | ||
|
|
4e88529e0a | ||
|
|
0bd3a8860f | ||
|
|
5e761dbbc6 | ||
|
|
5d422231ca | ||
|
|
b467b7ed1c | ||
|
|
b76e8f685d | ||
|
|
3be73ad264 | ||
|
|
c170b2c7fa | ||
|
|
52d7e48bdb | ||
|
|
d4e29a8093 | ||
|
|
6ee4b8cc58 | ||
|
|
b20edae256 | ||
|
|
d29dce0afc | ||
|
|
39eea67323 | ||
|
|
bd853e7f89 | ||
|
|
4e6e447e79 | ||
| e734e462c4 | |||
| c183861491 | |||
|
|
63b11eb2ad | ||
|
|
321694df9c | ||
| 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 |
34
.env.example
Normal file
34
.env.example
Normal file
@@ -0,0 +1,34 @@
|
||||
MONGO_URI=mongodb://localhost:27017/neomovies
|
||||
MONGO_DB_NAME=neomovies
|
||||
|
||||
TMDB_ACCESS_TOKEN=your_tmdb_access_token_here
|
||||
|
||||
KPAPI_KEY=920aaf6a-9f64-46f7-bda7-209fb1069440
|
||||
KPAPI_BASE_URL=https://kinopoiskapiunofficial.tech/api
|
||||
|
||||
HDVB_TOKEN=b9ae5f8c4832244060916af4aa9d1939
|
||||
|
||||
VIBIX_HOST=https://vibix.org
|
||||
VIBIX_TOKEN=18745|NzecUXT4gikPUtFkSEFlDLPmr9kWnQACTo1N0Ixq9240bcf1
|
||||
|
||||
LUMEX_URL=https://p.lumex.space
|
||||
|
||||
ALLOHA_TOKEN=your_alloha_token
|
||||
|
||||
REDAPI_BASE_URL=http://redapi.cfhttp.top
|
||||
REDAPI_KEY=your_redapi_key
|
||||
|
||||
JWT_SECRET=your_jwt_secret_key_here
|
||||
|
||||
GMAIL_USER=your_gmail@gmail.com
|
||||
GMAIL_APP_PASSWORD=your_gmail_app_password
|
||||
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback
|
||||
|
||||
BASE_URL=http://localhost:3000
|
||||
FRONTEND_URL=http://localhost:3001
|
||||
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
.env
|
||||
.env.local
|
||||
node_modules
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
# Binaries
|
||||
bin/
|
||||
main
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
neomovies-api
|
||||
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.
|
||||
363
README.md
363
README.md
@@ -1,87 +1,316 @@
|
||||
# Neo Movies API
|
||||
|
||||
API для поиска фильмов и сериалов с поддержкой русского языка.
|
||||
REST API для поиска и получения информации о фильмах, использующий TMDB API.
|
||||
|
||||
## Деплой на AlwaysData
|
||||
## Особенности
|
||||
|
||||
1. Создайте аккаунт на [AlwaysData](https://www.alwaysdata.com)
|
||||
- Интеграция с Kinopoisk API для русского контента
|
||||
- Автоматическое переключение между TMDB и Kinopoisk
|
||||
- Поиск фильмов и сериалов
|
||||
- Информация о фильмах
|
||||
- Популярные, топ-рейтинговые, предстоящие фильмы
|
||||
- Поддержка русских плееров (Alloha, Lumex, Vibix, HDVB)
|
||||
- Swagger документация
|
||||
- Полная поддержка русского языка
|
||||
|
||||
2. Настройте SSH ключ:
|
||||
```bash
|
||||
# Создайте SSH ключ если его нет
|
||||
ssh-keygen -t rsa -b 4096
|
||||
|
||||
# Скопируйте публичный ключ
|
||||
cat ~/.ssh/id_rsa.pub
|
||||
```
|
||||
Добавьте ключ в настройках AlwaysData (SSH Keys)
|
||||
## 🛠 Быстрый старт
|
||||
|
||||
3. Подключитесь по SSH:
|
||||
```bash
|
||||
# Замените username на ваш логин
|
||||
ssh username@ssh-username.alwaysdata.net
|
||||
```
|
||||
### Локальная разработка
|
||||
|
||||
4. Установите Go:
|
||||
```bash
|
||||
# Создайте директорию для Go
|
||||
mkdir -p $HOME/go/bin
|
||||
|
||||
# Скачайте и установите Go
|
||||
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
|
||||
```
|
||||
1. **Клонирование репозитория**
|
||||
```bash
|
||||
git clone https://gitlab.com/foxixus/neomovies-api.git
|
||||
cd neomovies-api
|
||||
```
|
||||
|
||||
5. Клонируйте репозиторий:
|
||||
```bash
|
||||
git clone https://github.com/ваш-username/neomovies-api.git
|
||||
cd neomovies-api
|
||||
```
|
||||
2. **Создание .env файла**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Заполните необходимые переменные
|
||||
```
|
||||
|
||||
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. Установите зависимости:
|
||||
3. **Установка зависимостей**
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
2. Запустите сервер:
|
||||
4. **Запуск**
|
||||
```bash
|
||||
go run main.go
|
||||
```
|
||||
|
||||
API будет доступно по адресу: http://localhost:8080
|
||||
API будет доступен на `http://localhost:3000`
|
||||
|
||||
## API Endpoints
|
||||
### Деплой на Vercel
|
||||
|
||||
- `GET /movies/search` - Поиск фильмов
|
||||
- `GET /movies/popular` - Популярные фильмы
|
||||
- `GET /movies/top-rated` - Лучшие фильмы
|
||||
- `GET /movies/upcoming` - Предстоящие фильмы
|
||||
- `GET /movies/:id` - Информация о фильме
|
||||
- `GET /health` - Проверка работоспособности API
|
||||
1. **Подключите репозиторий к Vercel**
|
||||
2. **Настройте переменные окружения** (см. список ниже)
|
||||
3. **Деплой произойдет автоматически**
|
||||
|
||||
Полная документация API доступна по адресу: `/swagger/index.html`
|
||||
## ⚙️ Переменные окружения
|
||||
|
||||
```bash
|
||||
# Обязательные
|
||||
MONGO_URI=mongodb://localhost:27017/neomovies
|
||||
MONGO_DB_NAME=neomovies
|
||||
TMDB_ACCESS_TOKEN=your_tmdb_access_token
|
||||
JWT_SECRET=your_jwt_secret_key
|
||||
|
||||
# Kinopoisk API
|
||||
KPAPI_KEY=your_kp_api_key
|
||||
KPAPI_BASE_URL=https://kinopoiskapiunofficial.tech/api
|
||||
|
||||
# Сервис
|
||||
PORT=3000
|
||||
BASE_URL=http://localhost:3000
|
||||
FRONTEND_URL=http://localhost:3001
|
||||
NODE_ENV=development
|
||||
|
||||
# Email (Gmail)
|
||||
GMAIL_USER=your_gmail@gmail.com
|
||||
GMAIL_APP_PASSWORD=your_gmail_app_password
|
||||
|
||||
# Русские плееры
|
||||
LUMEX_URL=https://p.lumex.space
|
||||
ALLOHA_TOKEN=your_alloha_token
|
||||
VIBIX_HOST=https://vibix.org
|
||||
VIBIX_TOKEN=your_vibix_token
|
||||
HDVB_TOKEN=your_hdvb_token
|
||||
|
||||
# Торренты (RedAPI)
|
||||
REDAPI_BASE_URL=http://redapi.cfhttp.top
|
||||
REDAPI_KEY=your_redapi_key
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback
|
||||
```
|
||||
|
||||
## 📋 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 # Похожие
|
||||
|
||||
# Плееры (новый формат с типом ID)
|
||||
GET /api/v1/players/alloha/{id_type}/{id} # Alloha плеер (kp/301 или imdb/tt0133093)
|
||||
GET /api/v1/players/lumex/{id_type}/{id} # Lumex плеер (kp/301 или imdb/tt0133093)
|
||||
GET /api/v1/players/vibix/{id_type}/{id} # Vibix плеер (kp/301 или imdb/tt0133093)
|
||||
GET /api/v1/players/hdvb/{id_type}/{id} # HDVB плеер (kp/301 или imdb/tt0133093)
|
||||
GET /api/v1/players/vidsrc/{media_type}/{imdb_id} # Vidsrc (только IMDB)
|
||||
GET /api/v1/players/vidlink/movie/{imdb_id} # Vidlink фильмы (только IMDB)
|
||||
GET /api/v1/players/vidlink/tv/{tmdb_id} # Vidlink сериалы (только TMDB)
|
||||
|
||||
# Торренты
|
||||
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** - данные о фильмах (международный контент)
|
||||
- **Kinopoisk API Unofficial** - данные о русском контенте
|
||||
- **Gmail SMTP** - email уведомления
|
||||
- **Vercel** - деплой и хостинг
|
||||
|
||||
## 🌍 Kinopoisk API интеграция
|
||||
|
||||
API автоматически переключается между TMDB и Kinopoisk в зависимости от языка запроса:
|
||||
|
||||
- **Русский язык (`lang=ru`)** → Kinopoisk API
|
||||
- Русские названия фильмов
|
||||
- Рейтинги Кинопоиска
|
||||
- Поддержка Kinopoisk ID
|
||||
|
||||
- **Английский язык (`lang=en`)** → TMDB API
|
||||
- Международные названия
|
||||
- Рейтинги IMDB/TMDB
|
||||
- Поддержка IMDB/TMDB ID
|
||||
|
||||
### Формат ID в плеерах
|
||||
|
||||
Все русские плееры поддерживают два типа идентификаторов:
|
||||
|
||||
```bash
|
||||
# По Kinopoisk ID (приоритет для русского контента)
|
||||
GET /api/v1/players/alloha/kp/301
|
||||
|
||||
# По IMDB ID (fallback)
|
||||
GET /api/v1/players/alloha/imdb/tt0133093
|
||||
|
||||
# Примеры для других плееров
|
||||
GET /api/v1/players/lumex/kp/301
|
||||
GET /api/v1/players/vibix/kp/301
|
||||
GET /api/v1/players/hdvb/kp/301
|
||||
```
|
||||
|
||||
## 🚀 Производительность
|
||||
|
||||
По сравнению с 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
|
||||
185
api/index.go
Normal file
185
api/index.go
Normal file
@@ -0,0 +1,185 @@
|
||||
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)
|
||||
kpService := services.NewKinopoiskService(globalCfg.KPAPIKey, globalCfg.KPAPIBaseURL)
|
||||
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, kpService)
|
||||
tvService := services.NewTVService(globalDB, tmdbService, kpService)
|
||||
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, kpService)
|
||||
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/{id_type}/{id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/lumex/{id_type}/{id}", playersHandler.GetLumexPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/vibix/{id_type}/{id}", playersHandler.GetVibixPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/hdvb/{id_type}/{id}", playersHandler.GetHDVBPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/vidsrc/{media_type}/{imdb_id}", playersHandler.GetVidsrcPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/vidlink/movie/{imdb_id}", playersHandler.GetVidlinkMoviePlayer).Methods("GET")
|
||||
api.HandleFunc("/players/vidlink/tv/{tmdb_id}", playersHandler.GetVidlinkTVPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/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")
|
||||
|
||||
// CORS configuration - allow all origins
|
||||
corsHandler := handlers.CORS(
|
||||
handlers.AllowedOrigins([]string{
|
||||
"*", // Allow all origins
|
||||
}),
|
||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"}),
|
||||
handlers.AllowedHeaders([]string{
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-Requested-With",
|
||||
"X-CSRF-Token",
|
||||
"Access-Control-Allow-Origin",
|
||||
"Access-Control-Allow-Headers",
|
||||
"Access-Control-Allow-Methods",
|
||||
"Access-Control-Allow-Credentials",
|
||||
}),
|
||||
handlers.ExposedHeaders([]string{
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Total-Count",
|
||||
}),
|
||||
handlers.MaxAge(3600),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
go 1.21.0
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.4
|
||||
toolchain go1.24.2
|
||||
|
||||
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/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.2
|
||||
go.mongodb.org/mongo-driver v1.11.6
|
||||
golang.org/x/crypto v0.17.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/bytedance/sonic v1.12.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
|
||||
github.com/gin-contrib/cors v1.7.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // 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
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/klauspost/compress v1.13.6 // indirect
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.1 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.3 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
|
||||
189
go.sum
189
go.sum
@@ -1,156 +1,75 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
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=
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM=
|
||||
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06/go.mod h1:/wotfjM8I3m8NuIHPz3S8k+CCYH80EqDT8ZeNLqMQm0=
|
||||
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/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
||||
github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
|
||||
github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
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/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
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/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
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/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
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/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.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
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/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
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 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
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/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
|
||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||
github.com/swaggo/swag v1.16.2 h1:28Pp+8DkQoV+HLzLx8RGJZXNGKbFqnuvSbAAtoxiY04=
|
||||
github.com/swaggo/swag v1.16.2/go.mod h1:6YzXnDcpr0767iOejs318CwYkCQqyGer6BizOg03f+E=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
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=
|
||||
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1 h1:VOMT+81stJgXW3CpHyqHN3AXDYIMsx56mEFrB37Mb/E=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3 h1:kdwGpVNwPFtjs98xCGkHjQtGKh86rDcRZN17QEMCOIs=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
go.mongodb.org/mongo-driver v1.11.6 h1:XM7G6PjiGAO5betLF13BIa5TlLUUE3uJ/2Ox3Lz1K+o=
|
||||
go.mongodb.org/mongo-driver v1.11.6/go.mod h1:G9TgswdsWjX4tmDA5zfs2+6AEPpYJwqblyjsfuh8oXY=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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-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-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.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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
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=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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-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-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/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"`
|
||||
}
|
||||
274
main.go
274
main.go
@@ -1,125 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"neomovies-api/internal/api"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
|
||||
_ "neomovies-api/docs"
|
||||
"neomovies-api/pkg/config"
|
||||
"neomovies-api/pkg/database"
|
||||
appHandlers "neomovies-api/pkg/handlers"
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/monitor"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
// @title Neo Movies API
|
||||
// @version 1.0
|
||||
// @description API для работы с фильмами
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
func main() {
|
||||
// Устанавливаем переменные окружения
|
||||
os.Setenv("GIN_MODE", "debug")
|
||||
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)
|
||||
if err := godotenv.Load(); err != nil {
|
||||
_ = err
|
||||
}
|
||||
|
||||
// Bridge API
|
||||
bridge := r.Group("/bridge")
|
||||
{
|
||||
// TMDB endpoints
|
||||
tmdb := bridge.Group("/tmdb")
|
||||
{
|
||||
// Movie endpoints
|
||||
movie := tmdb.Group("/movie")
|
||||
{
|
||||
movie.GET("/popular", api.GetTMDBPopularMovies)
|
||||
movie.GET("/top_rated", api.GetTMDBTopRatedMovies)
|
||||
movie.GET("/upcoming", api.GetTMDBUpcomingMovies)
|
||||
movie.GET("/:id", api.GetTMDBMovie)
|
||||
movie.GET("/:id/external_ids", api.GetTMDBMovieExternalIDs)
|
||||
}
|
||||
cfg := config.New()
|
||||
|
||||
// Search endpoints
|
||||
search := tmdb.Group("/search")
|
||||
{
|
||||
search.GET("/movie", api.SearchTMDBMovies)
|
||||
search.GET("/tv", api.SearchTMDBTV)
|
||||
}
|
||||
db, err := database.Connect(cfg.MongoURI, cfg.MongoDBName)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Failed to connect to database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer database.Disconnect()
|
||||
|
||||
// TV endpoints
|
||||
tv := tmdb.Group("/tv")
|
||||
{
|
||||
tv.GET("/:id/external_ids", api.GetTMDBTVExternalIDs)
|
||||
}
|
||||
tmdbService := services.NewTMDBService(cfg.TMDBAccessToken)
|
||||
kpService := services.NewKinopoiskService(cfg.KPAPIKey, cfg.KPAPIBaseURL)
|
||||
emailService := services.NewEmailService(cfg)
|
||||
authService := services.NewAuthService(db, cfg.JWTSecret, emailService, cfg.BaseURL, cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.GoogleRedirectURL, cfg.FrontendURL)
|
||||
|
||||
// Discover endpoints
|
||||
discover := tmdb.Group("/discover")
|
||||
{
|
||||
discover.GET("/movie", api.DiscoverMovies)
|
||||
discover.GET("/tv", api.DiscoverTV)
|
||||
}
|
||||
}
|
||||
movieService := services.NewMovieService(db, tmdbService, kpService)
|
||||
tvService := services.NewTVService(db, tmdbService, kpService)
|
||||
favoritesService := services.NewFavoritesService(db, tmdbService)
|
||||
torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey)
|
||||
reactionsService := services.NewReactionsService(db)
|
||||
|
||||
authHandler := appHandlers.NewAuthHandler(authService)
|
||||
movieHandler := appHandlers.NewMovieHandler(movieService)
|
||||
tvHandler := appHandlers.NewTVHandler(tvService)
|
||||
favoritesHandler := appHandlers.NewFavoritesHandler(favoritesService, cfg)
|
||||
docsHandler := appHandlers.NewDocsHandler()
|
||||
searchHandler := appHandlers.NewSearchHandler(tmdbService, kpService)
|
||||
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/{id_type}/{id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/lumex/{id_type}/{id}", playersHandler.GetLumexPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/vibix/{id_type}/{id}", playersHandler.GetVibixPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/vidsrc/{media_type}/{imdb_id}", playersHandler.GetVidsrcPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/vidlink/movie/{imdb_id}", playersHandler.GetVidlinkMoviePlayer).Methods("GET")
|
||||
api.HandleFunc("/players/vidlink/tv/{tmdb_id}", playersHandler.GetVidlinkTVPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/hdvb/{id_type}/{id}", playersHandler.GetHDVBPlayer).Methods("GET")
|
||||
|
||||
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
|
||||
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
|
||||
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")
|
||||
|
||||
// CORS configuration - allow all origins
|
||||
corsHandler := handlers.CORS(
|
||||
handlers.AllowedOrigins([]string{
|
||||
"*", // Allow all origins
|
||||
}),
|
||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"}),
|
||||
handlers.AllowedHeaders([]string{
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-Requested-With",
|
||||
"X-CSRF-Token",
|
||||
"Access-Control-Allow-Origin",
|
||||
"Access-Control-Allow-Headers",
|
||||
"Access-Control-Allow-Methods",
|
||||
"Access-Control-Allow-Credentials",
|
||||
}),
|
||||
handlers.ExposedHeaders([]string{
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Total-Count",
|
||||
}),
|
||||
handlers.MaxAge(3600),
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 := cfg.Port
|
||||
if port == "" {
|
||||
port = "3000"
|
||||
}
|
||||
|
||||
// Запускаем сервер
|
||||
port := os.Getenv("PORT")
|
||||
if err := r.Run(":" + port); err != nil {
|
||||
log.Fatal(err)
|
||||
if err := http.ListenAndServe(":"+port, finalHandler); err != nil {
|
||||
fmt.Printf("❌ Server failed to start: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
78
pkg/config/config.go
Normal file
78
pkg/config/config.go
Normal file
@@ -0,0 +1,78 @@
|
||||
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
|
||||
KPAPIKey string
|
||||
HDVBToken string
|
||||
KPAPIBaseURL 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, ""),
|
||||
KPAPIKey: getEnv(EnvKPAPIKey, ""),
|
||||
HDVBToken: getEnv(EnvHDVBToken, ""),
|
||||
KPAPIBaseURL: getEnv("KPAPI_BASE_URL", DefaultKPAPIBase),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
39
pkg/config/vars.go
Normal file
39
pkg/config/vars.go
Normal file
@@ -0,0 +1,39 @@
|
||||
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"
|
||||
EnvKPAPIKey = "KPAPI_KEY"
|
||||
EnvHDVBToken = "HDVB_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"
|
||||
DefaultKPAPIBase = "https://kinopoiskapiunofficial.tech/api"
|
||||
|
||||
// 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
|
||||
}
|
||||
1883
pkg/handlers/docs.go
Normal file
1883
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
|
||||
}
|
||||
35
pkg/handlers/lang_helper.go
Normal file
35
pkg/handlers/lang_helper.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// GetLanguage extracts the lang parameter from request and returns it with default "ru"
|
||||
// Supports both "lang" and "language" query parameters
|
||||
// Valid values: "ru", "en"
|
||||
// Default: "ru"
|
||||
func GetLanguage(r *http.Request) string {
|
||||
// Check "lang" parameter first (our new standard)
|
||||
lang := r.URL.Query().Get("lang")
|
||||
|
||||
// Fall back to "language" for backward compatibility
|
||||
if lang == "" {
|
||||
lang = r.URL.Query().Get("language")
|
||||
}
|
||||
|
||||
// Default to "ru" if not specified
|
||||
if lang == "" {
|
||||
return "ru-RU"
|
||||
}
|
||||
|
||||
// Convert short codes to TMDB format
|
||||
switch lang {
|
||||
case "en":
|
||||
return "en-US"
|
||||
case "ru":
|
||||
return "ru-RU"
|
||||
default:
|
||||
// Return as-is if already in correct format
|
||||
return lang
|
||||
}
|
||||
}
|
||||
252
pkg/handlers/movie.go
Normal file
252
pkg/handlers/movie.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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 := GetLanguage(r)
|
||||
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)
|
||||
rawID := vars["id"]
|
||||
|
||||
// Support formats: "123" (old), "kp_123", "tmdb_123"
|
||||
source := ""
|
||||
var id int
|
||||
if strings.Contains(rawID, "_") {
|
||||
parts := strings.SplitN(rawID, "_", 2)
|
||||
if len(parts) != 2 {
|
||||
http.Error(w, "Invalid ID format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
source = parts[0]
|
||||
parsed, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid numeric ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id = parsed
|
||||
} else {
|
||||
// Backward compatibility
|
||||
parsed, err := strconv.Atoi(rawID)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id = parsed
|
||||
}
|
||||
|
||||
language := GetLanguage(r)
|
||||
idType := r.URL.Query().Get("id_type")
|
||||
if source == "kp" || source == "tmdb" {
|
||||
idType = source
|
||||
}
|
||||
|
||||
movie, err := h.movieService.GetByID(id, language, idType)
|
||||
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 := GetLanguage(r)
|
||||
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 := GetLanguage(r)
|
||||
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 := GetLanguage(r)
|
||||
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 := GetLanguage(r)
|
||||
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 := GetLanguage(r)
|
||||
|
||||
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 := GetLanguage(r)
|
||||
|
||||
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
|
||||
}
|
||||
717
pkg/handlers/players.go
Normal file
717
pkg/handlers/players.go
Normal file
@@ -0,0 +1,717 @@
|
||||
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)
|
||||
idType := vars["id_type"]
|
||||
id := vars["id"]
|
||||
|
||||
if idType == "" || id == "" {
|
||||
log.Printf("Error: id_type or id is empty")
|
||||
http.Error(w, "id_type and id are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if idType != "kp" && idType != "imdb" {
|
||||
log.Printf("Error: invalid id_type: %s", idType)
|
||||
http.Error(w, "id_type must be 'kp' or 'imdb'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Processing %s ID: %s", idType, id)
|
||||
|
||||
if h.config.AllohaToken == "" {
|
||||
log.Printf("Error: ALLOHA_TOKEN is missing")
|
||||
http.Error(w, "Server misconfiguration: ALLOHA_TOKEN missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
idParam := fmt.Sprintf("%s=%s", idType, url.QueryEscape(id))
|
||||
apiURL := fmt.Sprintf("https://api.alloha.tv/?token=%s&%s", h.config.AllohaToken, idParam)
|
||||
log.Printf("Calling Alloha API: %s", apiURL)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Получаем параметры для сериалов
|
||||
season := r.URL.Query().Get("season")
|
||||
episode := r.URL.Query().Get("episode")
|
||||
translation := r.URL.Query().Get("translation")
|
||||
if translation == "" {
|
||||
translation = "66" // дефолтная озвучка
|
||||
}
|
||||
|
||||
// Используем iframe URL из API
|
||||
iframeCode := allohaResponse.Data.Iframe
|
||||
|
||||
// Если это не HTML код, а просто URL
|
||||
var playerURL string
|
||||
if !strings.Contains(iframeCode, "<") {
|
||||
playerURL = iframeCode
|
||||
// Добавляем параметры для сериалов
|
||||
if season != "" && episode != "" {
|
||||
separator := "?"
|
||||
if strings.Contains(playerURL, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
playerURL = fmt.Sprintf("%s%sseason=%s&episode=%s&translation=%s", playerURL, separator, season, episode, translation)
|
||||
}
|
||||
iframeCode = fmt.Sprintf(`<iframe src="%s" allowfullscreen style="border:none;width:100%%;height:100%%"></iframe>`, playerURL)
|
||||
}
|
||||
|
||||
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 %s: %s", idType, id)
|
||||
}
|
||||
|
||||
func (h *PlayersHandler) GetLumexPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("GetLumexPlayer called: %s %s", r.Method, r.URL.Path)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
idType := vars["id_type"]
|
||||
id := vars["id"]
|
||||
|
||||
if idType == "" || id == "" {
|
||||
log.Printf("Error: id_type or id is empty")
|
||||
http.Error(w, "id_type and id are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if idType != "kp" && idType != "imdb" {
|
||||
log.Printf("Error: invalid id_type: %s", idType)
|
||||
http.Error(w, "id_type must be 'kp' or 'imdb'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Processing %s ID: %s", idType, id)
|
||||
|
||||
if h.config.LumexURL == "" {
|
||||
log.Printf("Error: LUMEX_URL is missing")
|
||||
http.Error(w, "Server misconfiguration: LUMEX_URL missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var paramName string
|
||||
if idType == "kp" {
|
||||
paramName = "kinopoisk_id"
|
||||
} else {
|
||||
paramName = "imdb_id"
|
||||
}
|
||||
|
||||
playerURL := fmt.Sprintf("%s?%s=%s", h.config.LumexURL, paramName, id)
|
||||
log.Printf("Lumex URL: %s", playerURL)
|
||||
|
||||
iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, playerURL)
|
||||
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 %s: %s", idType, id)
|
||||
}
|
||||
|
||||
func (h *PlayersHandler) GetVibixPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("GetVibixPlayer called: %s %s", r.Method, r.URL.Path)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
idType := vars["id_type"]
|
||||
id := vars["id"]
|
||||
|
||||
if idType == "" || id == "" {
|
||||
log.Printf("Error: id_type or id is empty")
|
||||
http.Error(w, "id_type and id are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if idType != "kp" && idType != "imdb" {
|
||||
log.Printf("Error: invalid id_type: %s", idType)
|
||||
http.Error(w, "id_type must be 'kp' or 'imdb'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Processing %s ID: %s", idType, id)
|
||||
|
||||
if h.config.VibixToken == "" {
|
||||
log.Printf("Error: VIBIX_TOKEN is missing")
|
||||
http.Error(w, "Server misconfiguration: VIBIX_TOKEN missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vibixHost := h.config.VibixHost
|
||||
if vibixHost == "" {
|
||||
vibixHost = "https://vibix.org"
|
||||
}
|
||||
|
||||
var endpoint string
|
||||
if idType == "kp" {
|
||||
endpoint = "kinopoisk"
|
||||
} else {
|
||||
endpoint = "imdb"
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/api/v1/publisher/videos/%s/%s", vibixHost, endpoint, id)
|
||||
log.Printf("Calling Vibix API: %s", apiURL)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
log.Printf("Error creating Vibix request: %v", err)
|
||||
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", h.config.VibixToken)
|
||||
req.Header.Set("X-CSRF-TOKEN", "")
|
||||
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Error calling Vibix API: %v", err)
|
||||
http.Error(w, "Failed to fetch from Vibix API", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
log.Printf("Vibix API response status: %d", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Printf("Vibix API error: %d", resp.StatusCode)
|
||||
http.Error(w, fmt.Sprintf("Vibix API error: %d", resp.StatusCode), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Error reading Vibix response: %v", err)
|
||||
http.Error(w, "Failed to read Vibix response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Vibix API response body: %s", string(body))
|
||||
|
||||
var vibixResponse struct {
|
||||
ID interface{} `json:"id"`
|
||||
IframeURL string `json:"iframe_url"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &vibixResponse); err != nil {
|
||||
log.Printf("Error unmarshaling Vibix JSON: %v", err)
|
||||
http.Error(w, "Invalid JSON from Vibix", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
if vibixResponse.ID == nil || vibixResponse.IframeURL == "" {
|
||||
log.Printf("Video not found or empty iframe_url")
|
||||
http.Error(w, "Video not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Vibix использует только iframe_url без season/episode
|
||||
playerURL := vibixResponse.IframeURL
|
||||
log.Printf("🔗 Vibix iframe URL: %s", playerURL)
|
||||
|
||||
iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, playerURL)
|
||||
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>Vibix 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 Vibix player for %s: %s", idType, id)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// GetVidsrcPlayer handles Vidsrc.to player (uses IMDb ID for both movies and TV shows)
|
||||
func (h *PlayersHandler) GetVidsrcPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("GetVidsrcPlayer called: %s %s", r.Method, r.URL.Path)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
imdbId := vars["imdb_id"]
|
||||
mediaType := vars["media_type"] // "movie" or "tv"
|
||||
|
||||
if imdbId == "" || mediaType == "" {
|
||||
http.Error(w, "imdb_id and media_type are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var playerURL string
|
||||
if mediaType == "movie" {
|
||||
playerURL = fmt.Sprintf("https://vidsrc.to/embed/movie/%s", imdbId)
|
||||
} else if mediaType == "tv" {
|
||||
season := r.URL.Query().Get("season")
|
||||
episode := r.URL.Query().Get("episode")
|
||||
if season == "" || episode == "" {
|
||||
http.Error(w, "season and episode are required for TV shows", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
playerURL = fmt.Sprintf("https://vidsrc.to/embed/tv/%s/%s/%s", imdbId, season, episode)
|
||||
} else {
|
||||
http.Error(w, "Invalid media_type. Use 'movie' or 'tv'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Generated Vidsrc URL: %s", playerURL)
|
||||
|
||||
// Используем общий шаблон с кастомными контролами
|
||||
htmlDoc := getPlayerWithControlsHTML(playerURL, "Vidsrc Player")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(htmlDoc))
|
||||
|
||||
log.Printf("Successfully served Vidsrc player for %s: %s", mediaType, imdbId)
|
||||
}
|
||||
|
||||
// GetVidlinkMoviePlayer handles vidlink.pro player for movies (uses IMDb ID)
|
||||
func (h *PlayersHandler) GetVidlinkMoviePlayer(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("GetVidlinkMoviePlayer called: %s %s", r.Method, r.URL.Path)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
imdbId := vars["imdb_id"]
|
||||
|
||||
if imdbId == "" {
|
||||
http.Error(w, "imdb_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
playerURL := fmt.Sprintf("https://vidlink.pro/movie/%s", imdbId)
|
||||
|
||||
log.Printf("Generated Vidlink Movie URL: %s", playerURL)
|
||||
|
||||
// Используем общий шаблон с кастомными контролами
|
||||
htmlDoc := getPlayerWithControlsHTML(playerURL, "Vidlink Player")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(htmlDoc))
|
||||
|
||||
log.Printf("Successfully served Vidlink movie player: %s", imdbId)
|
||||
}
|
||||
|
||||
// GetVidlinkTVPlayer handles vidlink.pro player for TV shows (uses TMDB ID)
|
||||
func (h *PlayersHandler) GetHDVBPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("GetHDVBPlayer called: %s %s", r.Method, r.URL.Path)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
idType := vars["id_type"]
|
||||
id := vars["id"]
|
||||
|
||||
if idType == "" || id == "" {
|
||||
log.Printf("Error: id_type or id is empty")
|
||||
http.Error(w, "id_type and id are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if idType != "kp" && idType != "imdb" {
|
||||
log.Printf("Error: invalid id_type: %s", idType)
|
||||
http.Error(w, "id_type must be 'kp' or 'imdb'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Processing %s ID: %s", idType, id)
|
||||
|
||||
if h.config.HDVBToken == "" {
|
||||
log.Printf("Error: HDVB_TOKEN is missing")
|
||||
http.Error(w, "Server misconfiguration: HDVB_TOKEN missing", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var apiURL string
|
||||
if idType == "kp" {
|
||||
apiURL = fmt.Sprintf("https://apivb.com/api/videos.json?id_kp=%s&token=%s", id, h.config.HDVBToken)
|
||||
} else {
|
||||
apiURL = fmt.Sprintf("https://apivb.com/api/videos.json?imdb_id=%s&token=%s", id, h.config.HDVBToken)
|
||||
}
|
||||
log.Printf("HDVB API URL: %s", apiURL)
|
||||
|
||||
resp, err := http.Get(apiURL)
|
||||
if err != nil {
|
||||
log.Printf("Error fetching HDVB data: %v", err)
|
||||
http.Error(w, "Failed to fetch player data", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("Error reading HDVB response: %v", err)
|
||||
http.Error(w, "Failed to read player data", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var hdvbData []map[string]interface{}
|
||||
if err := json.Unmarshal(body, &hdvbData); err != nil {
|
||||
log.Printf("Error parsing HDVB JSON: %v, body: %s", err, string(body))
|
||||
http.Error(w, "Failed to parse player data", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if len(hdvbData) == 0 {
|
||||
log.Printf("No HDVB data found for ID: %s", id)
|
||||
http.Error(w, "No player data found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
iframeURL, ok := hdvbData[0]["iframe_url"].(string)
|
||||
if !ok || iframeURL == "" {
|
||||
log.Printf("No iframe_url in HDVB response for ID: %s", id)
|
||||
http.Error(w, "No player URL found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("HDVB iframe URL: %s", iframeURL)
|
||||
|
||||
iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, iframeURL)
|
||||
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>HDVB 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 HDVB player for %s: %s", idType, id)
|
||||
}
|
||||
|
||||
func (h *PlayersHandler) GetVidlinkTVPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("GetVidlinkTVPlayer called: %s %s", r.Method, r.URL.Path)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
tmdbId := vars["tmdb_id"]
|
||||
|
||||
if tmdbId == "" {
|
||||
http.Error(w, "tmdb_id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
season := r.URL.Query().Get("season")
|
||||
episode := r.URL.Query().Get("episode")
|
||||
if season == "" || episode == "" {
|
||||
http.Error(w, "season and episode are required for TV shows", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
playerURL := fmt.Sprintf("https://vidlink.pro/tv/%s/%s/%s", tmdbId, season, episode)
|
||||
|
||||
log.Printf("Generated Vidlink TV URL: %s", playerURL)
|
||||
|
||||
// Используем общий шаблон с кастомными контролами
|
||||
htmlDoc := getPlayerWithControlsHTML(playerURL, "Vidlink Player")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(htmlDoc))
|
||||
|
||||
log.Printf("Successfully served Vidlink TV player: %s S%sE%s", tmdbId, season, episode)
|
||||
}
|
||||
|
||||
// getPlayerWithControlsHTML возвращает HTML с плеером и overlay для блокировки кликов
|
||||
func getPlayerWithControlsHTML(playerURL, title string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='utf-8'/>
|
||||
<title>%s</title>
|
||||
<style>
|
||||
html,body{margin:0;height:100%%;overflow:hidden;background:#000;font-family:Arial,sans-serif;}
|
||||
#container{position:relative;width:100%%;height:100%%;}
|
||||
#player-iframe{position:absolute;top:0;left:0;width:100%%;height:100%%;border:none;}
|
||||
#overlay{position:absolute;top:0;left:0;width:100%%;height:100%%;z-index:10;pointer-events:none;}
|
||||
#controls{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,0.8));padding:20px;opacity:0;transition:opacity 0.3s;pointer-events:auto;z-index:20;}
|
||||
#container:hover #controls{opacity:1;}
|
||||
.btn{background:rgba(255,255,255,0.2);border:none;color:#fff;padding:12px 20px;margin:0 5px;border-radius:5px;cursor:pointer;font-size:16px;transition:background 0.2s;}
|
||||
.btn:hover{background:rgba(255,255,255,0.4);}
|
||||
.btn:active{background:rgba(255,255,255,0.6);}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<iframe id="player-iframe" src="%s" allowfullscreen allow="autoplay; encrypted-media; fullscreen; picture-in-picture"></iframe>
|
||||
<div id="overlay"></div>
|
||||
<div id="controls">
|
||||
<button class="btn" id="btn-fullscreen" title="Fullscreen">⛶ Fullscreen</button>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const overlay=document.getElementById('overlay');
|
||||
|
||||
// Блокируем клики на iframe (защита от рекламы)
|
||||
overlay.addEventListener('click',(e)=>{e.preventDefault();e.stopPropagation();});
|
||||
overlay.addEventListener('mousedown',(e)=>{e.preventDefault();e.stopPropagation();});
|
||||
|
||||
// Fullscreen
|
||||
document.getElementById('btn-fullscreen').addEventListener('click',()=>{
|
||||
if(!document.fullscreenElement){
|
||||
document.getElementById('container').requestFullscreen();
|
||||
}else{
|
||||
document.exitFullscreen();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`, title, playerURL)
|
||||
}
|
||||
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})
|
||||
}
|
||||
89
pkg/handlers/search.go
Normal file
89
pkg/handlers/search.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type SearchHandler struct {
|
||||
tmdbService *services.TMDBService
|
||||
kpService *services.KinopoiskService
|
||||
}
|
||||
|
||||
func NewSearchHandler(tmdbService *services.TMDBService, kpService *services.KinopoiskService) *SearchHandler {
|
||||
return &SearchHandler{
|
||||
tmdbService: tmdbService,
|
||||
kpService: kpService,
|
||||
}
|
||||
}
|
||||
|
||||
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 := GetLanguage(r)
|
||||
|
||||
if services.ShouldUseKinopoisk(language) {
|
||||
if h.kpService == nil {
|
||||
http.Error(w, "Kinopoisk service is not configured", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
kpSearch, err := h.kpService.SearchFilms(query, page)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
tmdbResp := services.MapKPSearchToTMDBResponse(kpSearch)
|
||||
multiResults := make([]models.MultiSearchResult, 0)
|
||||
for _, movie := range tmdbResp.Results {
|
||||
multiResults = append(multiResults, models.MultiSearchResult{
|
||||
ID: movie.ID,
|
||||
MediaType: "movie",
|
||||
Title: movie.Title,
|
||||
OriginalTitle: movie.OriginalTitle,
|
||||
Overview: movie.Overview,
|
||||
PosterPath: movie.PosterPath,
|
||||
BackdropPath: movie.BackdropPath,
|
||||
ReleaseDate: movie.ReleaseDate,
|
||||
VoteAverage: movie.VoteAverage,
|
||||
VoteCount: movie.VoteCount,
|
||||
Popularity: movie.Popularity,
|
||||
Adult: movie.Adult,
|
||||
OriginalLanguage: movie.OriginalLanguage,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: models.MultiSearchResponse{
|
||||
Page: page,
|
||||
Results: multiResults,
|
||||
TotalPages: tmdbResp.TotalPages,
|
||||
TotalResults: tmdbResp.TotalResults,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// EN/прочие языки — TMDB
|
||||
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,
|
||||
})
|
||||
}
|
||||
233
pkg/handlers/tv.go
Normal file
233
pkg/handlers/tv.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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 := GetLanguage(r)
|
||||
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)
|
||||
rawID := vars["id"]
|
||||
|
||||
// Support formats: "123" (old), "kp_123", "tmdb_123"
|
||||
source := ""
|
||||
var id int
|
||||
if strings.Contains(rawID, "_") {
|
||||
parts := strings.SplitN(rawID, "_", 2)
|
||||
if len(parts) != 2 {
|
||||
http.Error(w, "Invalid ID format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
source = parts[0]
|
||||
parsed, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid numeric ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id = parsed
|
||||
} else {
|
||||
// Backward compatibility
|
||||
parsed, err := strconv.Atoi(rawID)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id = parsed
|
||||
}
|
||||
|
||||
language := GetLanguage(r)
|
||||
idType := r.URL.Query().Get("id_type")
|
||||
if source == "kp" || source == "tmdb" {
|
||||
idType = source
|
||||
}
|
||||
|
||||
tvShow, err := h.tvService.GetByID(id, language, idType)
|
||||
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 := GetLanguage(r)
|
||||
|
||||
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 := GetLanguage(r)
|
||||
|
||||
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 := GetLanguage(r)
|
||||
|
||||
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 := GetLanguage(r)
|
||||
|
||||
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 := GetLanguage(r)
|
||||
|
||||
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 := GetLanguage(r)
|
||||
|
||||
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"`
|
||||
}
|
||||
338
pkg/models/movie.go
Normal file
338
pkg/models/movie.go
Normal file
@@ -0,0 +1,338 @@
|
||||
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"`
|
||||
KinopoiskID int `json:"kinopoisk_id,omitempty"`
|
||||
BelongsToCollection *Collection `json:"belongs_to_collection,omitempty"`
|
||||
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
|
||||
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
|
||||
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"`
|
||||
IMDbID string `json:"imdb_id,omitempty"`
|
||||
KinopoiskID int `json:"kinopoisk_id,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"`
|
||||
KinopoiskID int `json:"kinopoisk_id,omitempty"`
|
||||
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
|
||||
}
|
||||
261
pkg/services/kinopoisk.go
Normal file
261
pkg/services/kinopoisk.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KinopoiskService struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type KPFilm struct {
|
||||
KinopoiskId int `json:"kinopoiskId"`
|
||||
ImdbId string `json:"imdbId"`
|
||||
NameRu string `json:"nameRu"`
|
||||
NameEn string `json:"nameEn"`
|
||||
NameOriginal string `json:"nameOriginal"`
|
||||
PosterUrl string `json:"posterUrl"`
|
||||
PosterUrlPreview string `json:"posterUrlPreview"`
|
||||
CoverUrl string `json:"coverUrl"`
|
||||
LogoUrl string `json:"logoUrl"`
|
||||
ReviewsCount int `json:"reviewsCount"`
|
||||
RatingGoodReview float64 `json:"ratingGoodReview"`
|
||||
RatingGoodReviewVoteCount int `json:"ratingGoodReviewVoteCount"`
|
||||
RatingKinopoisk float64 `json:"ratingKinopoisk"`
|
||||
RatingKinopoiskVoteCount int `json:"ratingKinopoiskVoteCount"`
|
||||
RatingImdb float64 `json:"ratingImdb"`
|
||||
RatingImdbVoteCount int `json:"ratingImdbVoteCount"`
|
||||
RatingFilmCritics float64 `json:"ratingFilmCritics"`
|
||||
RatingFilmCriticsVoteCount int `json:"ratingFilmCriticsVoteCount"`
|
||||
RatingAwait float64 `json:"ratingAwait"`
|
||||
RatingAwaitCount int `json:"ratingAwaitCount"`
|
||||
RatingRfCritics float64 `json:"ratingRfCritics"`
|
||||
RatingRfCriticsVoteCount int `json:"ratingRfCriticsVoteCount"`
|
||||
WebUrl string `json:"webUrl"`
|
||||
Year int `json:"year"`
|
||||
FilmLength int `json:"filmLength"`
|
||||
Slogan string `json:"slogan"`
|
||||
Description string `json:"description"`
|
||||
ShortDescription string `json:"shortDescription"`
|
||||
EditorAnnotation string `json:"editorAnnotation"`
|
||||
IsTicketsAvailable bool `json:"isTicketsAvailable"`
|
||||
ProductionStatus string `json:"productionStatus"`
|
||||
Type string `json:"type"`
|
||||
RatingMpaa string `json:"ratingMpaa"`
|
||||
RatingAgeLimits string `json:"ratingAgeLimits"`
|
||||
HasImax bool `json:"hasImax"`
|
||||
Has3D bool `json:"has3d"`
|
||||
LastSync string `json:"lastSync"`
|
||||
Countries []struct {
|
||||
Country string `json:"country"`
|
||||
} `json:"countries"`
|
||||
Genres []struct {
|
||||
Genre string `json:"genre"`
|
||||
} `json:"genres"`
|
||||
StartYear int `json:"startYear"`
|
||||
EndYear int `json:"endYear"`
|
||||
Serial bool `json:"serial"`
|
||||
ShortFilm bool `json:"shortFilm"`
|
||||
Completed bool `json:"completed"`
|
||||
}
|
||||
|
||||
type KPSearchResponse struct {
|
||||
Keyword string `json:"keyword"`
|
||||
PagesCount int `json:"pagesCount"`
|
||||
Films []KPFilmShort `json:"films"`
|
||||
SearchFilmsCountResult int `json:"searchFilmsCountResult"`
|
||||
}
|
||||
|
||||
type KPFilmShort struct {
|
||||
FilmId int `json:"filmId"`
|
||||
NameRu string `json:"nameRu"`
|
||||
NameEn string `json:"nameEn"`
|
||||
Type string `json:"type"`
|
||||
Year string `json:"year"`
|
||||
Description string `json:"description"`
|
||||
FilmLength string `json:"filmLength"`
|
||||
Countries []KPCountry `json:"countries"`
|
||||
Genres []KPGenre `json:"genres"`
|
||||
Rating string `json:"rating"`
|
||||
RatingVoteCount int `json:"ratingVoteCount"`
|
||||
PosterUrl string `json:"posterUrl"`
|
||||
PosterUrlPreview string `json:"posterUrlPreview"`
|
||||
}
|
||||
|
||||
type KPCountry struct {
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
type KPGenre struct {
|
||||
Genre string `json:"genre"`
|
||||
}
|
||||
|
||||
type KPExternalSource struct {
|
||||
Source string `json:"source"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func NewKinopoiskService(apiKey, baseURL string) *KinopoiskService {
|
||||
return &KinopoiskService{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KinopoiskService) makeRequest(endpoint string, target interface{}) error {
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("X-API-KEY", s.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("Kinopoisk API error: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
|
||||
func (s *KinopoiskService) GetFilmByKinopoiskId(id int) (*KPFilm, error) {
|
||||
endpoint := fmt.Sprintf("%s/v2.2/films/%d", s.baseURL, id)
|
||||
var film KPFilm
|
||||
err := s.makeRequest(endpoint, &film)
|
||||
return &film, err
|
||||
}
|
||||
|
||||
func (s *KinopoiskService) GetFilmByImdbId(imdbId string) (*KPFilm, error) {
|
||||
endpoint := fmt.Sprintf("%s/v2.2/films?imdbId=%s", s.baseURL, imdbId)
|
||||
|
||||
var response struct {
|
||||
Films []KPFilm `json:"items"`
|
||||
}
|
||||
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(response.Films) == 0 {
|
||||
return nil, fmt.Errorf("film not found")
|
||||
}
|
||||
|
||||
return &response.Films[0], nil
|
||||
}
|
||||
|
||||
func (s *KinopoiskService) SearchFilms(keyword string, page int) (*KPSearchResponse, error) {
|
||||
endpoint := fmt.Sprintf("%s/v2.1/films/search-by-keyword?keyword=%s&page=%d", s.baseURL, keyword, page)
|
||||
var response KPSearchResponse
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
return &response, err
|
||||
}
|
||||
|
||||
func (s *KinopoiskService) GetExternalSources(kinopoiskId int) ([]KPExternalSource, error) {
|
||||
endpoint := fmt.Sprintf("%s/v2.2/films/%d/external_sources", s.baseURL, kinopoiskId)
|
||||
|
||||
var response struct {
|
||||
Items []KPExternalSource `json:"items"`
|
||||
}
|
||||
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Items, nil
|
||||
}
|
||||
|
||||
func (s *KinopoiskService) GetTopFilms(topType string, page int) (*KPSearchResponse, error) {
|
||||
endpoint := fmt.Sprintf("%s/v2.2/films/top?type=%s&page=%d", s.baseURL, topType, page)
|
||||
|
||||
var response struct {
|
||||
PagesCount int `json:"pagesCount"`
|
||||
Films []KPFilmShort `json:"films"`
|
||||
}
|
||||
|
||||
err := s.makeRequest(endpoint, &response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &KPSearchResponse{
|
||||
PagesCount: response.PagesCount,
|
||||
Films: response.Films,
|
||||
SearchFilmsCountResult: len(response.Films),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func KPIdToImdbId(kpService *KinopoiskService, kpId int) (string, error) {
|
||||
film, err := kpService.GetFilmByKinopoiskId(kpId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return film.ImdbId, nil
|
||||
}
|
||||
|
||||
func ImdbIdToKPId(kpService *KinopoiskService, imdbId string) (int, error) {
|
||||
film, err := kpService.GetFilmByImdbId(imdbId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return film.KinopoiskId, nil
|
||||
}
|
||||
|
||||
func TmdbIdToKPId(tmdbService *TMDBService, kpService *KinopoiskService, tmdbId int) (int, error) {
|
||||
externalIds, err := tmdbService.GetMovieExternalIDs(tmdbId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if externalIds.IMDbID == "" {
|
||||
return 0, fmt.Errorf("no IMDb ID found for TMDB ID %d", tmdbId)
|
||||
}
|
||||
|
||||
return ImdbIdToKPId(kpService, externalIds.IMDbID)
|
||||
}
|
||||
|
||||
func KPIdToTmdbId(tmdbService *TMDBService, kpService *KinopoiskService, kpId int) (int, error) {
|
||||
imdbId, err := KPIdToImdbId(kpService, kpId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
movies, err := tmdbService.SearchMovies("", 1, "en-US", "", 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, movie := range movies.Results {
|
||||
ids, err := tmdbService.GetMovieExternalIDs(movie.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ids.IMDbID == imdbId {
|
||||
return movie.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("TMDB ID not found for KP ID %d", kpId)
|
||||
}
|
||||
|
||||
func ConvertKPRating(rating float64) float64 {
|
||||
return rating
|
||||
}
|
||||
|
||||
func FormatKPYear(year int) string {
|
||||
return strconv.Itoa(year)
|
||||
}
|
||||
390
pkg/services/kp_mapper.go
Normal file
390
pkg/services/kp_mapper.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
func MapKPFilmToTMDBMovie(kpFilm *KPFilm) *models.Movie {
|
||||
if kpFilm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
releaseDate := ""
|
||||
if kpFilm.Year > 0 {
|
||||
releaseDate = fmt.Sprintf("%d-01-01", kpFilm.Year)
|
||||
}
|
||||
|
||||
genres := make([]models.Genre, 0)
|
||||
for _, g := range kpFilm.Genres {
|
||||
genres = append(genres, models.Genre{
|
||||
ID: 0,
|
||||
Name: g.Genre,
|
||||
})
|
||||
}
|
||||
|
||||
countries := make([]models.ProductionCountry, 0)
|
||||
for _, c := range kpFilm.Countries {
|
||||
countries = append(countries, models.ProductionCountry{
|
||||
ISO31661: "",
|
||||
Name: c.Country,
|
||||
})
|
||||
}
|
||||
|
||||
posterPath := ""
|
||||
if kpFilm.PosterUrlPreview != "" {
|
||||
posterPath = kpFilm.PosterUrlPreview
|
||||
} else if kpFilm.PosterUrl != "" {
|
||||
posterPath = kpFilm.PosterUrl
|
||||
}
|
||||
|
||||
backdropPath := ""
|
||||
if kpFilm.CoverUrl != "" {
|
||||
backdropPath = kpFilm.CoverUrl
|
||||
}
|
||||
|
||||
overview := kpFilm.Description
|
||||
if overview == "" {
|
||||
overview = kpFilm.ShortDescription
|
||||
}
|
||||
|
||||
title := kpFilm.NameRu
|
||||
if title == "" {
|
||||
title = kpFilm.NameEn
|
||||
}
|
||||
if title == "" {
|
||||
title = kpFilm.NameOriginal
|
||||
}
|
||||
|
||||
originalTitle := kpFilm.NameOriginal
|
||||
if originalTitle == "" {
|
||||
originalTitle = kpFilm.NameEn
|
||||
}
|
||||
|
||||
return &models.Movie{
|
||||
ID: kpFilm.KinopoiskId,
|
||||
Title: title,
|
||||
OriginalTitle: originalTitle,
|
||||
Overview: overview,
|
||||
PosterPath: posterPath,
|
||||
BackdropPath: backdropPath,
|
||||
ReleaseDate: releaseDate,
|
||||
VoteAverage: kpFilm.RatingKinopoisk,
|
||||
VoteCount: kpFilm.RatingKinopoiskVoteCount,
|
||||
Popularity: float64(kpFilm.RatingKinopoisk * 100),
|
||||
Adult: false,
|
||||
OriginalLanguage: detectLanguage(kpFilm),
|
||||
Runtime: kpFilm.FilmLength,
|
||||
Genres: genres,
|
||||
Tagline: kpFilm.Slogan,
|
||||
ProductionCountries: countries,
|
||||
IMDbID: kpFilm.ImdbId,
|
||||
KinopoiskID: kpFilm.KinopoiskId,
|
||||
}
|
||||
}
|
||||
|
||||
func MapKPFilmToTVShow(kpFilm *KPFilm) *models.TVShow {
|
||||
if kpFilm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
firstAirDate := ""
|
||||
if kpFilm.StartYear > 0 {
|
||||
firstAirDate = fmt.Sprintf("%d-01-01", kpFilm.StartYear)
|
||||
}
|
||||
|
||||
lastAirDate := ""
|
||||
if kpFilm.EndYear > 0 {
|
||||
lastAirDate = fmt.Sprintf("%d-01-01", kpFilm.EndYear)
|
||||
}
|
||||
|
||||
genres := make([]models.Genre, 0)
|
||||
for _, g := range kpFilm.Genres {
|
||||
genres = append(genres, models.Genre{
|
||||
ID: 0,
|
||||
Name: g.Genre,
|
||||
})
|
||||
}
|
||||
|
||||
posterPath := ""
|
||||
if kpFilm.PosterUrlPreview != "" {
|
||||
posterPath = kpFilm.PosterUrlPreview
|
||||
} else if kpFilm.PosterUrl != "" {
|
||||
posterPath = kpFilm.PosterUrl
|
||||
}
|
||||
|
||||
backdropPath := ""
|
||||
if kpFilm.CoverUrl != "" {
|
||||
backdropPath = kpFilm.CoverUrl
|
||||
}
|
||||
|
||||
overview := kpFilm.Description
|
||||
if overview == "" {
|
||||
overview = kpFilm.ShortDescription
|
||||
}
|
||||
|
||||
name := kpFilm.NameRu
|
||||
if name == "" {
|
||||
name = kpFilm.NameEn
|
||||
}
|
||||
if name == "" {
|
||||
name = kpFilm.NameOriginal
|
||||
}
|
||||
|
||||
originalName := kpFilm.NameOriginal
|
||||
if originalName == "" {
|
||||
originalName = kpFilm.NameEn
|
||||
}
|
||||
|
||||
status := "Ended"
|
||||
if kpFilm.Completed {
|
||||
status = "Ended"
|
||||
} else {
|
||||
status = "Returning Series"
|
||||
}
|
||||
|
||||
return &models.TVShow{
|
||||
ID: kpFilm.KinopoiskId,
|
||||
Name: name,
|
||||
OriginalName: originalName,
|
||||
Overview: overview,
|
||||
PosterPath: posterPath,
|
||||
BackdropPath: backdropPath,
|
||||
FirstAirDate: firstAirDate,
|
||||
LastAirDate: lastAirDate,
|
||||
VoteAverage: kpFilm.RatingKinopoisk,
|
||||
VoteCount: kpFilm.RatingKinopoiskVoteCount,
|
||||
Popularity: float64(kpFilm.RatingKinopoisk * 100),
|
||||
OriginalLanguage: detectLanguage(kpFilm),
|
||||
Genres: genres,
|
||||
Status: status,
|
||||
InProduction: !kpFilm.Completed,
|
||||
KinopoiskID: kpFilm.KinopoiskId,
|
||||
}
|
||||
}
|
||||
|
||||
// Unified mappers with prefixed IDs
|
||||
func MapKPToUnified(kpFilm *KPFilm) *models.UnifiedContent {
|
||||
if kpFilm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
releaseDate := FormatKPDate(kpFilm.Year)
|
||||
endDate := (*string)(nil)
|
||||
if kpFilm.EndYear > 0 {
|
||||
v := FormatKPDate(kpFilm.EndYear)
|
||||
endDate = &v
|
||||
}
|
||||
|
||||
genres := make([]models.UnifiedGenre, 0)
|
||||
for _, g := range kpFilm.Genres {
|
||||
genres = append(genres, models.UnifiedGenre{ID: strings.ToLower(g.Genre), Name: g.Genre})
|
||||
}
|
||||
|
||||
poster := kpFilm.PosterUrlPreview
|
||||
if poster == "" {
|
||||
poster = kpFilm.PosterUrl
|
||||
}
|
||||
|
||||
country := ""
|
||||
if len(kpFilm.Countries) > 0 {
|
||||
country = kpFilm.Countries[0].Country
|
||||
}
|
||||
|
||||
title := kpFilm.NameRu
|
||||
if title == "" {
|
||||
title = kpFilm.NameEn
|
||||
}
|
||||
originalTitle := kpFilm.NameOriginal
|
||||
if originalTitle == "" {
|
||||
originalTitle = kpFilm.NameEn
|
||||
}
|
||||
|
||||
var budgetPtr *int64
|
||||
var revenuePtr *int64
|
||||
|
||||
external := models.UnifiedExternalIDs{KP: &kpFilm.KinopoiskId, TMDB: nil, IMDb: kpFilm.ImdbId}
|
||||
|
||||
return &models.UnifiedContent{
|
||||
ID: strconv.Itoa(kpFilm.KinopoiskId),
|
||||
SourceID: "kp_" + strconv.Itoa(kpFilm.KinopoiskId),
|
||||
Title: title,
|
||||
OriginalTitle: originalTitle,
|
||||
Description: firstNonEmpty(kpFilm.Description, kpFilm.ShortDescription),
|
||||
ReleaseDate: releaseDate,
|
||||
EndDate: endDate,
|
||||
Type: mapKPTypeToUnified(kpFilm),
|
||||
Genres: genres,
|
||||
Rating: kpFilm.RatingKinopoisk,
|
||||
PosterURL: poster,
|
||||
BackdropURL: kpFilm.CoverUrl,
|
||||
Director: "",
|
||||
Cast: []models.UnifiedCastMember{},
|
||||
Duration: kpFilm.FilmLength,
|
||||
Country: country,
|
||||
Language: detectLanguage(kpFilm),
|
||||
Budget: budgetPtr,
|
||||
Revenue: revenuePtr,
|
||||
IMDbID: kpFilm.ImdbId,
|
||||
ExternalIDs: external,
|
||||
}
|
||||
}
|
||||
|
||||
func mapKPTypeToUnified(kp *KPFilm) string {
|
||||
if kp.Serial || kp.Type == "TV_SERIES" || kp.Type == "MINI_SERIES" {
|
||||
return "tv"
|
||||
}
|
||||
return "movie"
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func MapKPSearchToTMDBResponse(kpSearch *KPSearchResponse) *models.TMDBResponse {
|
||||
if kpSearch == nil {
|
||||
return &models.TMDBResponse{
|
||||
Page: 1,
|
||||
Results: []models.Movie{},
|
||||
TotalPages: 0,
|
||||
TotalResults: 0,
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]models.Movie, 0)
|
||||
for _, film := range kpSearch.Films {
|
||||
movie := mapKPFilmShortToMovie(film)
|
||||
if movie != nil {
|
||||
results = append(results, *movie)
|
||||
}
|
||||
}
|
||||
|
||||
totalPages := kpSearch.PagesCount
|
||||
if totalPages == 0 && len(results) > 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
return &models.TMDBResponse{
|
||||
Page: 1,
|
||||
Results: results,
|
||||
TotalPages: totalPages,
|
||||
TotalResults: kpSearch.SearchFilmsCountResult,
|
||||
}
|
||||
}
|
||||
|
||||
func mapKPFilmShortToMovie(film KPFilmShort) *models.Movie {
|
||||
genres := make([]models.Genre, 0)
|
||||
for _, g := range film.Genres {
|
||||
genres = append(genres, models.Genre{
|
||||
ID: 0,
|
||||
Name: g.Genre,
|
||||
})
|
||||
}
|
||||
|
||||
year := 0
|
||||
if film.Year != "" {
|
||||
year, _ = strconv.Atoi(film.Year)
|
||||
}
|
||||
|
||||
releaseDate := ""
|
||||
if year > 0 {
|
||||
releaseDate = fmt.Sprintf("%d-01-01", year)
|
||||
}
|
||||
|
||||
posterPath := film.PosterUrlPreview
|
||||
if posterPath == "" {
|
||||
posterPath = film.PosterUrl
|
||||
}
|
||||
|
||||
title := film.NameRu
|
||||
if title == "" {
|
||||
title = film.NameEn
|
||||
}
|
||||
|
||||
originalTitle := film.NameEn
|
||||
if originalTitle == "" {
|
||||
originalTitle = film.NameRu
|
||||
}
|
||||
|
||||
rating := 0.0
|
||||
if film.Rating != "" {
|
||||
rating, _ = strconv.ParseFloat(film.Rating, 64)
|
||||
}
|
||||
|
||||
return &models.Movie{
|
||||
ID: film.FilmId,
|
||||
Title: title,
|
||||
OriginalTitle: originalTitle,
|
||||
Overview: film.Description,
|
||||
PosterPath: posterPath,
|
||||
ReleaseDate: releaseDate,
|
||||
VoteAverage: rating,
|
||||
VoteCount: film.RatingVoteCount,
|
||||
Popularity: rating * 100,
|
||||
Genres: genres,
|
||||
KinopoiskID: film.FilmId,
|
||||
}
|
||||
}
|
||||
|
||||
func detectLanguage(film *KPFilm) string {
|
||||
if film.NameRu != "" {
|
||||
return "ru"
|
||||
}
|
||||
if film.NameEn != "" {
|
||||
return "en"
|
||||
}
|
||||
return "ru"
|
||||
}
|
||||
|
||||
func MapKPExternalIDsToTMDB(kpFilm *KPFilm) *models.ExternalIDs {
|
||||
if kpFilm == nil {
|
||||
return &models.ExternalIDs{}
|
||||
}
|
||||
|
||||
return &models.ExternalIDs{
|
||||
ID: kpFilm.KinopoiskId,
|
||||
IMDbID: kpFilm.ImdbId,
|
||||
KinopoiskID: kpFilm.KinopoiskId,
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldUseKinopoisk(language string) bool {
|
||||
if language == "" {
|
||||
return false
|
||||
}
|
||||
lang := strings.ToLower(language)
|
||||
return strings.HasPrefix(lang, "ru")
|
||||
}
|
||||
|
||||
func NormalizeLanguage(language string) string {
|
||||
if language == "" {
|
||||
return "en-US"
|
||||
}
|
||||
|
||||
lang := strings.ToLower(language)
|
||||
if strings.HasPrefix(lang, "ru") {
|
||||
return "ru-RU"
|
||||
}
|
||||
|
||||
return "en-US"
|
||||
}
|
||||
|
||||
func ConvertKPRatingToTMDB(kpRating float64) float64 {
|
||||
return kpRating
|
||||
}
|
||||
|
||||
func FormatKPDate(year int) string {
|
||||
if year <= 0 {
|
||||
return time.Now().Format("2006-01-02")
|
||||
}
|
||||
return fmt.Sprintf("%d-01-01", year)
|
||||
}
|
||||
127
pkg/services/movie.go
Normal file
127
pkg/services/movie.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type MovieService struct {
|
||||
tmdb *TMDBService
|
||||
kpService *KinopoiskService
|
||||
}
|
||||
|
||||
func NewMovieService(db *mongo.Database, tmdb *TMDBService, kpService *KinopoiskService) *MovieService {
|
||||
return &MovieService{
|
||||
tmdb: tmdb,
|
||||
kpService: kpService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MovieService) Search(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
|
||||
if ShouldUseKinopoisk(language) && s.kpService != nil {
|
||||
kpSearch, err := s.kpService.SearchFilms(query, page)
|
||||
if err == nil {
|
||||
return MapKPSearchToTMDBResponse(kpSearch), nil
|
||||
}
|
||||
}
|
||||
return s.tmdb.SearchMovies(query, page, language, region, year)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetByID(id int, language string, idType string) (*models.Movie, error) {
|
||||
// Строго уважаем явный id_type, без скрытого fallback на TMDB
|
||||
switch idType {
|
||||
case "kp":
|
||||
if s.kpService == nil {
|
||||
return nil, fmt.Errorf("kinopoisk service not configured")
|
||||
}
|
||||
|
||||
// Сначала пробуем как Kinopoisk ID
|
||||
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(id); err == nil {
|
||||
return MapKPFilmToTMDBMovie(kpFilm), nil
|
||||
}
|
||||
|
||||
// Возможно пришел TMDB ID — пробуем конвертировать TMDB -> KP
|
||||
if kpId, convErr := TmdbIdToKPId(s.tmdb, s.kpService, id); convErr == nil {
|
||||
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(kpId); err == nil {
|
||||
return MapKPFilmToTMDBMovie(kpFilm), nil
|
||||
}
|
||||
}
|
||||
// Явно указан KP, но ничего не нашли — возвращаем ошибку
|
||||
return nil, fmt.Errorf("film not found in Kinopoisk with id %d", id)
|
||||
|
||||
case "tmdb":
|
||||
return s.tmdb.GetMovie(id, language)
|
||||
}
|
||||
|
||||
// Если id_type не указан — старая логика по языку
|
||||
if ShouldUseKinopoisk(language) && s.kpService != nil {
|
||||
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(id); err == nil {
|
||||
return MapKPFilmToTMDBMovie(kpFilm), nil
|
||||
}
|
||||
}
|
||||
|
||||
return s.tmdb.GetMovie(id, language)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetPopular(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
if ShouldUseKinopoisk(language) && s.kpService != nil {
|
||||
kpTop, err := s.kpService.GetTopFilms("TOP_100_POPULAR_FILMS", page)
|
||||
if err == nil {
|
||||
return MapKPSearchToTMDBResponse(kpTop), nil
|
||||
}
|
||||
}
|
||||
return s.tmdb.GetPopularMovies(page, language, region)
|
||||
}
|
||||
|
||||
func (s *MovieService) GetTopRated(page int, language, region string) (*models.TMDBResponse, error) {
|
||||
if ShouldUseKinopoisk(language) && s.kpService != nil {
|
||||
kpTop, err := s.kpService.GetTopFilms("TOP_250_BEST_FILMS", page)
|
||||
if err == nil {
|
||||
return MapKPSearchToTMDBResponse(kpTop), nil
|
||||
}
|
||||
}
|
||||
return s.tmdb.GetTopRatedMovies(page, language, region)
|
||||
}
|
||||
|
||||
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) {
|
||||
if s.kpService != nil {
|
||||
kpFilm, err := s.kpService.GetFilmByKinopoiskId(id)
|
||||
if err == nil && kpFilm != nil {
|
||||
externalIDs := MapKPExternalIDsToTMDB(kpFilm)
|
||||
externalIDs.ID = id
|
||||
return externalIDs, nil
|
||||
}
|
||||
}
|
||||
|
||||
tmdbIDs, err := s.tmdb.GetMovieExternalIDs(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.kpService != nil && tmdbIDs.IMDbID != "" {
|
||||
kpFilm, err := s.kpService.GetFilmByImdbId(tmdbIDs.IMDbID)
|
||||
if err == nil && kpFilm != nil {
|
||||
tmdbIDs.KinopoiskID = kpFilm.KinopoiskId
|
||||
}
|
||||
}
|
||||
|
||||
return tmdbIDs, nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
590
pkg/services/tmdb.go
Normal file
590
pkg/services/tmdb.go
Normal file
@@ -0,0 +1,590 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// Map TMDB movie to unified content with prefixed IDs. Requires optional external IDs for imdbId.
|
||||
func MapTMDBToUnifiedMovie(movie *models.Movie, external *models.ExternalIDs) *models.UnifiedContent {
|
||||
if movie == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
genres := make([]models.UnifiedGenre, 0, len(movie.Genres))
|
||||
for _, g := range movie.Genres {
|
||||
name := strings.TrimSpace(g.Name)
|
||||
id := strings.ToLower(strings.ReplaceAll(name, " ", "-"))
|
||||
if id == "" {
|
||||
id = strconv.Itoa(g.ID)
|
||||
}
|
||||
genres = append(genres, models.UnifiedGenre{ID: id, Name: name})
|
||||
}
|
||||
|
||||
var imdb string
|
||||
if external != nil {
|
||||
imdb = external.IMDbID
|
||||
}
|
||||
|
||||
var budgetPtr *int64
|
||||
if movie.Budget > 0 {
|
||||
v := movie.Budget
|
||||
budgetPtr = &v
|
||||
}
|
||||
|
||||
var revenuePtr *int64
|
||||
if movie.Revenue > 0 {
|
||||
v := movie.Revenue
|
||||
revenuePtr = &v
|
||||
}
|
||||
|
||||
ext := models.UnifiedExternalIDs{
|
||||
KP: nil,
|
||||
TMDB: &movie.ID,
|
||||
IMDb: imdb,
|
||||
}
|
||||
|
||||
return &models.UnifiedContent{
|
||||
ID: strconv.Itoa(movie.ID),
|
||||
SourceID: "tmdb_" + strconv.Itoa(movie.ID),
|
||||
Title: movie.Title,
|
||||
OriginalTitle: movie.OriginalTitle,
|
||||
Description: movie.Overview,
|
||||
ReleaseDate: movie.ReleaseDate,
|
||||
EndDate: nil,
|
||||
Type: "movie",
|
||||
Genres: genres,
|
||||
Rating: movie.VoteAverage,
|
||||
PosterURL: movie.PosterPath,
|
||||
BackdropURL: movie.BackdropPath,
|
||||
Director: "",
|
||||
Cast: []models.UnifiedCastMember{},
|
||||
Duration: movie.Runtime,
|
||||
Country: firstCountry(movie.ProductionCountries),
|
||||
Language: movie.OriginalLanguage,
|
||||
Budget: budgetPtr,
|
||||
Revenue: revenuePtr,
|
||||
IMDbID: imdb,
|
||||
ExternalIDs: ext,
|
||||
}
|
||||
}
|
||||
|
||||
func firstCountry(countries []models.ProductionCountry) string {
|
||||
if len(countries) == 0 {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(countries[0].Name) != "" {
|
||||
return countries[0].Name
|
||||
}
|
||||
return countries[0].ISO31661
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
90
pkg/services/tv.go
Normal file
90
pkg/services/tv.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type TVService struct {
|
||||
db *mongo.Database
|
||||
tmdb *TMDBService
|
||||
kpService *KinopoiskService
|
||||
}
|
||||
|
||||
func NewTVService(db *mongo.Database, tmdb *TMDBService, kpService *KinopoiskService) *TVService {
|
||||
return &TVService{
|
||||
db: db,
|
||||
tmdb: tmdb,
|
||||
kpService: kpService,
|
||||
}
|
||||
}
|
||||
|
||||
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, idType string) (*models.TVShow, error) {
|
||||
// Строго уважаем явный id_type, без скрытого fallback на TMDB
|
||||
switch idType {
|
||||
case "kp":
|
||||
if s.kpService == nil {
|
||||
return nil, fmt.Errorf("kinopoisk service not configured")
|
||||
}
|
||||
|
||||
// Сначала пробуем как Kinopoisk ID
|
||||
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(id); err == nil && kpFilm != nil {
|
||||
return MapKPFilmToTVShow(kpFilm), nil
|
||||
}
|
||||
|
||||
// Возможно пришел TMDB ID — пробуем конвертировать TMDB -> KP
|
||||
if kpId, convErr := TmdbIdToKPId(s.tmdb, s.kpService, id); convErr == nil {
|
||||
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(kpId); err == nil && kpFilm != nil {
|
||||
return MapKPFilmToTVShow(kpFilm), nil
|
||||
}
|
||||
}
|
||||
// Явно указан KP, но ничего не нашли — возвращаем ошибку
|
||||
return nil, fmt.Errorf("TV show not found in Kinopoisk with id %d", id)
|
||||
|
||||
case "tmdb":
|
||||
return s.tmdb.GetTVShow(id, language)
|
||||
}
|
||||
|
||||
// Если id_type не указан — старая логика по языку
|
||||
if ShouldUseKinopoisk(language) && s.kpService != nil {
|
||||
if kpFilm, err := s.kpService.GetFilmByKinopoiskId(id); err == nil && kpFilm != nil {
|
||||
return MapKPFilmToTVShow(kpFilm), nil
|
||||
}
|
||||
}
|
||||
|
||||
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