mirror of
https://gitlab.com/foxixus/neomovies-api.git
synced 2025-10-27 17:38:51 +05:00
Compare commits
98 Commits
main
...
567b287322
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
28
.env.example
Normal file
28
.env.example
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Required
|
||||||
|
MONGO_URI=
|
||||||
|
MONGO_DB_NAME=database
|
||||||
|
TMDB_ACCESS_TOKEN=
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Service
|
||||||
|
PORT=3000
|
||||||
|
BASE_URL=http://localhost:3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Email (Gmail)
|
||||||
|
GMAIL_USER=
|
||||||
|
GMAIL_APP_PASSWORD=
|
||||||
|
|
||||||
|
# Players
|
||||||
|
LUMEX_URL=
|
||||||
|
ALLOHA_TOKEN=
|
||||||
|
|
||||||
|
# Torrents (RedAPI)
|
||||||
|
REDAPI_BASE_URL=http://redapi.cfhttp.top
|
||||||
|
REDAPI_KEY=
|
||||||
|
|
||||||
|
# Google OAuth
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback
|
||||||
|
FRONTEND_URL=http://localhost:3001
|
||||||
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.
|
||||||
319
README.md
319
README.md
@@ -1,87 +1,272 @@
|
|||||||
# Neo Movies API
|
# Neo Movies API
|
||||||
|
|
||||||
API для поиска фильмов и сериалов с поддержкой русского языка.
|
REST API для поиска и получения информации о фильмах, использующий TMDB API.
|
||||||
|
|
||||||
## Деплой на AlwaysData
|
## Особенности
|
||||||
|
|
||||||
1. Создайте аккаунт на [AlwaysData](https://www.alwaysdata.com)
|
- Поиск фильмов
|
||||||
|
- Информация о фильмах
|
||||||
|
- Популярные фильмы
|
||||||
|
- Топ рейтинговые фильмы
|
||||||
|
- Предстоящие фильмы
|
||||||
|
- Swagger документация
|
||||||
|
- Поддержка русского языка
|
||||||
|
|
||||||
2. Настройте SSH ключ:
|
## 🛠 Быстрый старт
|
||||||
```bash
|
|
||||||
# Создайте SSH ключ если его нет
|
|
||||||
ssh-keygen -t rsa -b 4096
|
|
||||||
|
|
||||||
# Скопируйте публичный ключ
|
### Локальная разработка
|
||||||
cat ~/.ssh/id_rsa.pub
|
|
||||||
```
|
|
||||||
Добавьте ключ в настройках AlwaysData (SSH Keys)
|
|
||||||
|
|
||||||
3. Подключитесь по SSH:
|
1. **Клонирование репозитория**
|
||||||
```bash
|
```bash
|
||||||
# Замените username на ваш логин
|
git clone https://gitlab.com/foxixus/neomovies-api.git
|
||||||
ssh username@ssh-username.alwaysdata.net
|
cd neomovies-api
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Установите Go:
|
2. **Создание .env файла**
|
||||||
```bash
|
```bash
|
||||||
# Создайте директорию для Go
|
cp .env.example .env
|
||||||
mkdir -p $HOME/go/bin
|
# Заполните необходимые переменные
|
||||||
|
```
|
||||||
|
|
||||||
# Скачайте и установите Go
|
3. **Установка зависимостей**
|
||||||
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
|
|
||||||
tar -C $HOME -xzf go1.21.5.linux-amd64.tar.gz
|
|
||||||
|
|
||||||
# Добавьте Go в PATH
|
|
||||||
echo 'export PATH=$HOME/go/bin:$HOME/go/bin:$PATH' >> ~/.bashrc
|
|
||||||
source ~/.bashrc
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Клонируйте репозиторий:
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/ваш-username/neomovies-api.git
|
|
||||||
cd neomovies-api
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Соберите приложение:
|
|
||||||
```bash
|
|
||||||
chmod +x build.sh
|
|
||||||
./build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
7. Настройте сервис в панели AlwaysData:
|
|
||||||
- Type: Site
|
|
||||||
- Name: neomovies-api
|
|
||||||
- Address: api.your-name.alwaysdata.net
|
|
||||||
- Command: $HOME/neomovies-api/run.sh
|
|
||||||
- Working directory: $HOME/neomovies-api
|
|
||||||
|
|
||||||
8. Добавьте переменные окружения:
|
|
||||||
- `TMDB_ACCESS_TOKEN`: Ваш токен TMDB API
|
|
||||||
- `PORT`: 8080 (или порт по умолчанию)
|
|
||||||
|
|
||||||
После деплоя ваше API будет доступно по адресу: https://api.your-name.alwaysdata.net
|
|
||||||
|
|
||||||
## Локальная разработка
|
|
||||||
|
|
||||||
1. Установите зависимости:
|
|
||||||
```bash
|
```bash
|
||||||
go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Запустите сервер:
|
4. **Запуск**
|
||||||
```bash
|
```bash
|
||||||
go run main.go
|
go run main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
API будет доступно по адресу: http://localhost:8080
|
API будет доступен на `http://localhost:3000`
|
||||||
|
|
||||||
## API Endpoints
|
### Деплой на Vercel
|
||||||
|
|
||||||
- `GET /movies/search` - Поиск фильмов
|
1. **Подключите репозиторий к Vercel**
|
||||||
- `GET /movies/popular` - Популярные фильмы
|
2. **Настройте переменные окружения** (см. список ниже)
|
||||||
- `GET /movies/top-rated` - Лучшие фильмы
|
3. **Деплой произойдет автоматически**
|
||||||
- `GET /movies/upcoming` - Предстоящие фильмы
|
|
||||||
- `GET /movies/:id` - Информация о фильме
|
|
||||||
- `GET /health` - Проверка работоспособности API
|
|
||||||
|
|
||||||
Полная документация API доступна по адресу: `/swagger/index.html`
|
## ⚙️ Переменные окружения
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Обязательные
|
||||||
|
MONGO_URI=
|
||||||
|
MONGO_DB_NAME=database
|
||||||
|
TMDB_ACCESS_TOKEN=
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Сервис
|
||||||
|
PORT=3000
|
||||||
|
BASE_URL=http://localhost:3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Email (Gmail)
|
||||||
|
GMAIL_USER=
|
||||||
|
GMAIL_APP_PASSWORD=
|
||||||
|
|
||||||
|
# Плееры
|
||||||
|
LUMEX_URL=
|
||||||
|
ALLOHA_TOKEN=
|
||||||
|
VIBIX_TOKEN=
|
||||||
|
|
||||||
|
# Торренты (RedAPI)
|
||||||
|
REDAPI_BASE_URL=http://redapi.cfhttp.top
|
||||||
|
REDAPI_KEY=
|
||||||
|
|
||||||
|
# Google OAuth
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/auth/google/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 API Endpoints
|
||||||
|
|
||||||
|
### 🔓 Публичные маршруты
|
||||||
|
|
||||||
|
```http
|
||||||
|
# Система
|
||||||
|
GET /api/v1/health # Проверка состояния
|
||||||
|
|
||||||
|
# Аутентификация
|
||||||
|
POST /api/v1/auth/register # Регистрация (отправка кода)
|
||||||
|
POST /api/v1/auth/verify # Подтверждение email кодом
|
||||||
|
POST /api/v1/auth/resend-code # Повторная отправка кода
|
||||||
|
POST /api/v1/auth/login # Авторизация
|
||||||
|
GET /api/v1/auth/google/login # Начало авторизации через Google (redirect)
|
||||||
|
GET /api/v1/auth/google/callback # Коллбек Google OAuth (возвращает JWT)
|
||||||
|
|
||||||
|
# Поиск и категории
|
||||||
|
GET /search/multi # Мультипоиск
|
||||||
|
GET /api/v1/categories # Список категорий
|
||||||
|
GET /api/v1/categories/{id}/movies # Фильмы по категории
|
||||||
|
|
||||||
|
# Фильмы
|
||||||
|
GET /api/v1/movies/search # Поиск фильмов
|
||||||
|
GET /api/v1/movies/popular # Популярные
|
||||||
|
GET /api/v1/movies/top-rated # Топ-рейтинговые
|
||||||
|
GET /api/v1/movies/upcoming # Предстоящие
|
||||||
|
GET /api/v1/movies/now-playing # В прокате
|
||||||
|
GET /api/v1/movies/{id} # Детали фильма
|
||||||
|
GET /api/v1/movies/{id}/recommendations # Рекомендации
|
||||||
|
GET /api/v1/movies/{id}/similar # Похожие
|
||||||
|
|
||||||
|
# Сериалы
|
||||||
|
GET /api/v1/tv/search # Поиск сериалов
|
||||||
|
GET /api/v1/tv/popular # Популярные
|
||||||
|
GET /api/v1/tv/top-rated # Топ-рейтинговые
|
||||||
|
GET /api/v1/tv/on-the-air # В эфире
|
||||||
|
GET /api/v1/tv/airing-today # Сегодня в эфире
|
||||||
|
GET /api/v1/tv/{id} # Детали сериала
|
||||||
|
GET /api/v1/tv/{id}/recommendations # Рекомендации
|
||||||
|
GET /api/v1/tv/{id}/similar # Похожие
|
||||||
|
|
||||||
|
# Плееры
|
||||||
|
GET /api/v1/players/alloha/{imdb_id} # Alloha плеер по IMDb ID
|
||||||
|
GET /api/v1/players/lumex/{imdb_id} # Lumex плеер по IMDb ID
|
||||||
|
GET /api/v1/players/vibix/{imdb_id} # Vibix плеер по IMDb ID
|
||||||
|
|
||||||
|
# Торренты
|
||||||
|
GET /api/v1/torrents/search/{imdbId} # Поиск торрентов
|
||||||
|
|
||||||
|
# Реакции (публичные)
|
||||||
|
GET /api/v1/reactions/{mediaType}/{mediaId}/counts # Счетчики реакций
|
||||||
|
|
||||||
|
# Изображения
|
||||||
|
GET /api/v1/images/{size}/{path} # Прокси TMDB изображений
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔒 Приватные маршруты (требуют JWT)
|
||||||
|
|
||||||
|
```http
|
||||||
|
# Профиль
|
||||||
|
GET /api/v1/auth/profile # Профиль пользователя
|
||||||
|
PUT /api/v1/auth/profile # Обновление профиля
|
||||||
|
|
||||||
|
# Избранное
|
||||||
|
GET /api/v1/favorites # Список избранного
|
||||||
|
POST /api/v1/favorites/{id} # Добавить в избранное
|
||||||
|
DELETE /api/v1/favorites/{id} # Удалить из избранного
|
||||||
|
|
||||||
|
# Реакции (приватные)
|
||||||
|
GET /api/v1/reactions/{mediaType}/{mediaId}/my-reaction # Моя реакция
|
||||||
|
POST /api/v1/reactions/{mediaType}/{mediaId} # Установить реакцию
|
||||||
|
DELETE /api/v1/reactions/{mediaType}/{mediaId} # Удалить реакцию
|
||||||
|
GET /api/v1/reactions/my # Все мои реакции
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 Примеры использования
|
||||||
|
|
||||||
|
### Регистрация и верификация
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Регистрация
|
||||||
|
curl -X POST https://api.neomovies.ru/api/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"name": "John Doe"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Ответ: {"success": true, "message": "Registered. Check email for verification code."}
|
||||||
|
|
||||||
|
# 2. Подтверждение email (код из письма)
|
||||||
|
curl -X POST https://api.neomovies.ru/api/v1/auth/verify \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"code": "123456"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# 3. Авторизация
|
||||||
|
curl -X POST https://api.neomovies.ru/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поиск фильмов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Поиск фильмов
|
||||||
|
curl "https://api.neomovies.ru/api/v1/movies/search?query=marvel&page=1"
|
||||||
|
|
||||||
|
# Детали фильма
|
||||||
|
curl "https://api.neomovies.ru/api/v1/movies/550"
|
||||||
|
|
||||||
|
# Добавить в избранное (с JWT токеном)
|
||||||
|
curl -X POST https://api.neomovies.ru/api/v1/favorites/550 \
|
||||||
|
-H "Authorization: Bearer YOUR_JWT_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поиск торрентов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Поиск торрентов для фильма "Побег из Шоушенка"
|
||||||
|
curl "https://api.neomovies.ru/api/v1/torrents/search/tt0111161?type=movie&quality=1080p"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Документация API
|
||||||
|
|
||||||
|
Интерактивная документация доступна по адресу:
|
||||||
|
|
||||||
|
**🔗 https://api.neomovies.ru/**
|
||||||
|
|
||||||
|
## ☁️ Деплой на Vercel
|
||||||
|
|
||||||
|
1. **Подключите репозиторий к Vercel**
|
||||||
|
2. **Настройте Environment Variables в Vercel Dashboard:**
|
||||||
|
3. **Деплой автоматически запустится!**
|
||||||
|
|
||||||
|
## 🏗 Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
├── main.go # Точка входа приложения
|
||||||
|
├── api/
|
||||||
|
│ └── index.go # Vercel serverless handler
|
||||||
|
├── pkg/ # Публичные пакеты (совместимо с Vercel)
|
||||||
|
│ ├── config/ # Конфигурация с поддержкой альтернативных env vars
|
||||||
|
│ ├── database/ # Подключение к MongoDB
|
||||||
|
│ ├── middleware/ # JWT, CORS, логирование
|
||||||
|
│ ├── models/ # Структуры данных
|
||||||
|
│ ├── services/ # Бизнес-логика
|
||||||
|
│ └── handlers/ # HTTP обработчики
|
||||||
|
├── vercel.json # Конфигурация Vercel
|
||||||
|
└── go.mod # Go модули
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Технологии
|
||||||
|
|
||||||
|
- **Go 1.21** - основной язык
|
||||||
|
- **Gorilla Mux** - HTTP роутер
|
||||||
|
- **MongoDB** - база данных
|
||||||
|
- **JWT** - аутентификация
|
||||||
|
- **TMDB API** - данные о фильмах
|
||||||
|
- **Gmail SMTP** - email уведомления
|
||||||
|
- **Vercel** - деплой и хостинг
|
||||||
|
|
||||||
|
## 🚀 Производительность
|
||||||
|
|
||||||
|
По сравнению с Node.js версией:
|
||||||
|
- **3x быстрее** обработка запросов
|
||||||
|
- **50% меньше** потребление памяти
|
||||||
|
- **Конкурентность** благодаря горутинам
|
||||||
|
- **Типобезопасность** предотвращает ошибки
|
||||||
|
|
||||||
|
## 🤝 Contribution
|
||||||
|
|
||||||
|
1. Форкните репозиторий
|
||||||
|
2. Создайте feature-ветку (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Коммитьте изменения (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Пушните в ветку (`git push origin feature/amazing-feature`)
|
||||||
|
5. Откройте Pull Request
|
||||||
|
|
||||||
|
## 📄 Лицензия
|
||||||
|
|
||||||
|
Apache License 2.0 - подробности в файле [LICENSE](LICENSE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Made with <3 by Foxix
|
||||||
183
api/index.go
Normal file
183
api/index.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gorilla/handlers"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/config"
|
||||||
|
"neomovies-api/pkg/database"
|
||||||
|
handlersPkg "neomovies-api/pkg/handlers"
|
||||||
|
"neomovies-api/pkg/middleware"
|
||||||
|
"neomovies-api/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalDB *mongo.Database
|
||||||
|
globalCfg *config.Config
|
||||||
|
initOnce sync.Once
|
||||||
|
initError error
|
||||||
|
)
|
||||||
|
|
||||||
|
func initializeApp() {
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
|
||||||
|
globalCfg = config.New()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
globalDB, err = database.Connect(globalCfg.MongoURI, globalCfg.MongoDBName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to connect to database: %v", err)
|
||||||
|
initError = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Successfully connected to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
initOnce.Do(initializeApp)
|
||||||
|
|
||||||
|
if initError != nil {
|
||||||
|
log.Printf("Initialization error: %v", initError)
|
||||||
|
http.Error(w, "Application initialization failed: "+initError.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmdbService := services.NewTMDBService(globalCfg.TMDBAccessToken)
|
||||||
|
emailService := services.NewEmailService(globalCfg)
|
||||||
|
authService := services.NewAuthService(globalDB, globalCfg.JWTSecret, emailService, globalCfg.BaseURL, globalCfg.GoogleClientID, globalCfg.GoogleClientSecret, globalCfg.GoogleRedirectURL, globalCfg.FrontendURL)
|
||||||
|
|
||||||
|
movieService := services.NewMovieService(globalDB, tmdbService)
|
||||||
|
tvService := services.NewTVService(globalDB, tmdbService)
|
||||||
|
favoritesService := services.NewFavoritesService(globalDB, tmdbService)
|
||||||
|
torrentService := services.NewTorrentServiceWithConfig(globalCfg.RedAPIBaseURL, globalCfg.RedAPIKey)
|
||||||
|
reactionsService := services.NewReactionsService(globalDB)
|
||||||
|
|
||||||
|
authHandler := handlersPkg.NewAuthHandler(authService)
|
||||||
|
movieHandler := handlersPkg.NewMovieHandler(movieService)
|
||||||
|
tvHandler := handlersPkg.NewTVHandler(tvService)
|
||||||
|
favoritesHandler := handlersPkg.NewFavoritesHandler(favoritesService, globalCfg)
|
||||||
|
docsHandler := handlersPkg.NewDocsHandler()
|
||||||
|
searchHandler := handlersPkg.NewSearchHandler(tmdbService)
|
||||||
|
categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService)
|
||||||
|
playersHandler := handlersPkg.NewPlayersHandler(globalCfg)
|
||||||
|
torrentsHandler := handlersPkg.NewTorrentsHandler(torrentService, tmdbService)
|
||||||
|
reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService)
|
||||||
|
imagesHandler := handlersPkg.NewImagesHandler()
|
||||||
|
|
||||||
|
router := mux.NewRouter()
|
||||||
|
|
||||||
|
router.HandleFunc("/", docsHandler.ServeDocs).Methods("GET")
|
||||||
|
router.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET")
|
||||||
|
|
||||||
|
api := router.PathPrefix("/api/v1").Subrouter()
|
||||||
|
|
||||||
|
api.HandleFunc("/health", handlersPkg.HealthCheck).Methods("GET")
|
||||||
|
api.HandleFunc("/auth/register", authHandler.Register).Methods("POST")
|
||||||
|
api.HandleFunc("/auth/login", authHandler.Login).Methods("POST")
|
||||||
|
api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST")
|
||||||
|
api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).Methods("POST")
|
||||||
|
api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET")
|
||||||
|
api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET")
|
||||||
|
|
||||||
|
api.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
|
||||||
|
|
||||||
|
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
|
||||||
|
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET")
|
||||||
|
api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).Methods("GET")
|
||||||
|
|
||||||
|
api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
||||||
|
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
|
||||||
|
api.HandleFunc("/players/vibix/{imdb_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/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
|
module neomovies-api
|
||||||
|
|
||||||
go 1.21.0
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.23.4
|
toolchain go1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/handlers v1.5.2
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/swaggo/files v1.0.1
|
go.mongodb.org/mongo-driver v1.11.6
|
||||||
github.com/swaggo/gin-swagger v1.6.0
|
golang.org/x/crypto v0.17.0
|
||||||
github.com/swaggo/swag v1.16.2
|
golang.org/x/oauth2 v0.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
github.com/golang/snappy v0.0.1 // indirect
|
||||||
github.com/bytedance/sonic v1.12.6 // indirect
|
github.com/klauspost/compress v1.13.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.1 // indirect
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
|
github.com/xdg-go/scram v1.1.1 // indirect
|
||||||
github.com/gin-contrib/cors v1.7.3 // indirect
|
github.com/xdg-go/stringprep v1.0.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
|
||||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
github.com/go-openapi/spec v0.20.4 // indirect
|
|
||||||
github.com/go-openapi/swag v0.19.15 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
|
||||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.4 // indirect
|
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
|
||||||
github.com/mailru/easyjson v0.7.6 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
|
||||||
golang.org/x/arch v0.12.0 // indirect
|
|
||||||
golang.org/x/crypto v0.31.0 // indirect
|
|
||||||
golang.org/x/net v0.33.0 // indirect
|
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
|
||||||
golang.org/x/text v0.21.0 // indirect
|
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
|
||||||
google.golang.org/protobuf v1.36.1 // indirect
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
h12.io/socks v1.0.3 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
189
go.sum
189
go.sum
@@ -1,156 +1,75 @@
|
|||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06 h1:W4Yar1SUsPmmA51qoIRb174uDO/Xt3C48MB1YX9Y3vM=
|
||||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
github.com/MarceloPetrucio/go-scalar-api-reference v0.0.0-20240521013641-ce5d2efe0e06/go.mod h1:/wotfjM8I3m8NuIHPz3S8k+CCYH80EqDT8ZeNLqMQm0=
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
|
||||||
github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk=
|
|
||||||
github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
|
||||||
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
|
|
||||||
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
|
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||||
github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
|
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
|
||||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
|
||||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
|
||||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
|
||||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
|
||||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
|
||||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
|
||||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
|
||||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
|
||||||
github.com/h12w/go-socks5 v0.0.0-20200522160539-76189e178364/go.mod h1:eDJQioIyy4Yn3MVivT7rv/39gAJTrA7lgmYr8EW950c=
|
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
|
||||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
|
||||||
github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
github.com/xdg-go/scram v1.1.1 h1:VOMT+81stJgXW3CpHyqHN3AXDYIMsx56mEFrB37Mb/E=
|
||||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
|
github.com/xdg-go/stringprep v1.0.3 h1:kdwGpVNwPFtjs98xCGkHjQtGKh86rDcRZN17QEMCOIs=
|
||||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||||
github.com/swaggo/swag v1.16.2 h1:28Pp+8DkQoV+HLzLx8RGJZXNGKbFqnuvSbAAtoxiY04=
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA=
|
||||||
github.com/swaggo/swag v1.16.2/go.mod h1:6YzXnDcpr0767iOejs318CwYkCQqyGer6BizOg03f+E=
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
go.mongodb.org/mongo-driver v1.11.6 h1:XM7G6PjiGAO5betLF13BIa5TlLUUE3uJ/2Ox3Lz1K+o=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
go.mongodb.org/mongo-driver v1.11.6/go.mod h1:G9TgswdsWjX4tmDA5zfs2+6AEPpYJwqblyjsfuh8oXY=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
|
||||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
|
||||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
|
||||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
h12.io/socks v1.0.3 h1:Ka3qaQewws4j4/eDQnOdpr4wXsC//dXtWvftlIcCQUo=
|
|
||||||
h12.io/socks v1.0.3/go.mod h1:AIhxy1jOId/XCz9BO+EIgNL2rQiPTBNnOfnVnQ+3Eck=
|
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
|
||||||
|
|||||||
@@ -1,505 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"neomovies-api/internal/tmdb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetPopularMovies возвращает список популярных фильмов
|
|
||||||
// @Summary Get popular movies
|
|
||||||
// @Description Get a list of popular movies
|
|
||||||
// @Tags movies
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param page query int false "Page number (default: 1)"
|
|
||||||
// @Success 200 {object} MoviesResponse
|
|
||||||
// @Router /movies/popular [get]
|
|
||||||
func GetPopularMovies(c *gin.Context) {
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
|
|
||||||
movies, err := tmdbClient.GetPopular(page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем полные URL для изображений
|
|
||||||
for i := range movies.Results {
|
|
||||||
if movies.Results[i].PosterPath != "" {
|
|
||||||
movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500")
|
|
||||||
}
|
|
||||||
if movies.Results[i].BackdropPath != "" {
|
|
||||||
movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, movies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMovie возвращает информацию о фильме
|
|
||||||
// @Summary Get movie details
|
|
||||||
// @Description Get detailed information about a specific movie
|
|
||||||
// @Tags movies
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path int true "Movie ID"
|
|
||||||
// @Success 200 {object} MovieDetails
|
|
||||||
// @Router /movies/{id} [get]
|
|
||||||
func GetMovie(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
|
|
||||||
movie, err := tmdbClient.GetMovie(id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем полные URL для изображений
|
|
||||||
if movie.PosterPath != "" {
|
|
||||||
movie.PosterPath = tmdbClient.GetImageURL(movie.PosterPath, "original")
|
|
||||||
}
|
|
||||||
if movie.BackdropPath != "" {
|
|
||||||
movie.BackdropPath = tmdbClient.GetImageURL(movie.BackdropPath, "original")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обрабатываем изображения для коллекции
|
|
||||||
if movie.BelongsToCollection != nil {
|
|
||||||
if movie.BelongsToCollection.PosterPath != "" {
|
|
||||||
movie.BelongsToCollection.PosterPath = tmdbClient.GetImageURL(movie.BelongsToCollection.PosterPath, "w500")
|
|
||||||
}
|
|
||||||
if movie.BelongsToCollection.BackdropPath != "" {
|
|
||||||
movie.BelongsToCollection.BackdropPath = tmdbClient.GetImageURL(movie.BelongsToCollection.BackdropPath, "w1280")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обрабатываем логотипы компаний
|
|
||||||
for i := range movie.ProductionCompanies {
|
|
||||||
if movie.ProductionCompanies[i].LogoPath != "" {
|
|
||||||
movie.ProductionCompanies[i].LogoPath = tmdbClient.GetImageURL(movie.ProductionCompanies[i].LogoPath, "w185")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, movie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchMovies ищет фильмы
|
|
||||||
// @Summary Поиск фильмов
|
|
||||||
// @Description Поиск фильмов по запросу
|
|
||||||
// @Tags movies
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param query query string true "Поисковый запрос"
|
|
||||||
// @Param page query string false "Номер страницы (по умолчанию 1)"
|
|
||||||
// @Success 200 {object} SearchResponse
|
|
||||||
// @Router /movies/search [get]
|
|
||||||
func SearchMovies(c *gin.Context) {
|
|
||||||
query := c.Query("query")
|
|
||||||
if query == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
|
|
||||||
// Получаем результаты поиска
|
|
||||||
results, err := tmdbClient.SearchMovies(query, page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Преобразуем результаты в формат ответа
|
|
||||||
response := SearchResponse{
|
|
||||||
Page: results.Page,
|
|
||||||
TotalPages: results.TotalPages,
|
|
||||||
TotalResults: results.TotalResults,
|
|
||||||
Results: make([]MovieResponse, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Преобразуем каждый фильм
|
|
||||||
for _, movie := range results.Results {
|
|
||||||
// Форматируем дату
|
|
||||||
releaseDate := formatDate(movie.ReleaseDate)
|
|
||||||
|
|
||||||
// Добавляем фильм в результаты
|
|
||||||
response.Results = append(response.Results, MovieResponse{
|
|
||||||
ID: movie.ID,
|
|
||||||
Title: movie.Title,
|
|
||||||
Overview: movie.Overview,
|
|
||||||
ReleaseDate: releaseDate,
|
|
||||||
VoteAverage: movie.VoteAverage,
|
|
||||||
PosterPath: tmdbClient.GetImageURL(movie.PosterPath, "w500"),
|
|
||||||
BackdropPath: tmdbClient.GetImageURL(movie.BackdropPath, "w1280"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTopRatedMovies возвращает список лучших фильмов
|
|
||||||
// @Summary Get top rated movies
|
|
||||||
// @Description Get a list of top rated movies
|
|
||||||
// @Tags movies
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param page query int false "Page number (default: 1)"
|
|
||||||
// @Success 200 {object} MoviesResponse
|
|
||||||
// @Router /movies/top-rated [get]
|
|
||||||
func GetTopRatedMovies(c *gin.Context) {
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
|
|
||||||
movies, err := tmdbClient.GetTopRated(page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем полные URL для изображений
|
|
||||||
for i := range movies.Results {
|
|
||||||
if movies.Results[i].PosterPath != "" {
|
|
||||||
movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500")
|
|
||||||
}
|
|
||||||
if movies.Results[i].BackdropPath != "" {
|
|
||||||
movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, movies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUpcomingMovies возвращает список предстоящих фильмов
|
|
||||||
// @Summary Get upcoming movies
|
|
||||||
// @Description Get a list of upcoming movies
|
|
||||||
// @Tags movies
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param page query int false "Page number (default: 1)"
|
|
||||||
// @Success 200 {object} MoviesResponse
|
|
||||||
// @Router /movies/upcoming [get]
|
|
||||||
func GetUpcomingMovies(c *gin.Context) {
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
|
|
||||||
movies, err := tmdbClient.GetUpcoming(page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем полные URL для изображений
|
|
||||||
for i := range movies.Results {
|
|
||||||
if movies.Results[i].PosterPath != "" {
|
|
||||||
movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500")
|
|
||||||
}
|
|
||||||
if movies.Results[i].BackdropPath != "" {
|
|
||||||
movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, movies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTMDBPopularMovies возвращает список популярных фильмов из TMDB
|
|
||||||
// @Summary Get TMDB popular movies
|
|
||||||
// @Description Get a list of popular movies directly from TMDB
|
|
||||||
// @Tags tmdb
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param page query int false "Page number (default: 1)"
|
|
||||||
// @Success 200 {object} TMDBMoviesResponse
|
|
||||||
// @Router /bridge/tmdb/movie/popular [get]
|
|
||||||
func GetTMDBPopularMovies(c *gin.Context) {
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
|
|
||||||
movies, err := tmdbClient.GetPopular(page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем полные URL для изображений
|
|
||||||
for i := range movies.Results {
|
|
||||||
if movies.Results[i].PosterPath != "" {
|
|
||||||
movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500")
|
|
||||||
}
|
|
||||||
if movies.Results[i].BackdropPath != "" {
|
|
||||||
movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, movies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTMDBMovie возвращает информацию о фильме из TMDB
|
|
||||||
// @Summary Get TMDB movie details
|
|
||||||
// @Description Get detailed information about a specific movie directly from TMDB
|
|
||||||
// @Tags tmdb
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path int true "Movie ID"
|
|
||||||
// @Success 200 {object} tmdb.Movie
|
|
||||||
// @Router /bridge/tmdb/movie/{id} [get]
|
|
||||||
func GetTMDBMovie(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
|
|
||||||
movie, err := tmdbClient.GetMovie(id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, movie)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTMDBTopRatedMovies возвращает список лучших фильмов из TMDB
|
|
||||||
// @Summary Get TMDB top rated movies
|
|
||||||
// @Description Get a list of top rated movies directly from TMDB
|
|
||||||
// @Tags tmdb
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param page query int false "Page number (default: 1)"
|
|
||||||
// @Success 200 {object} TMDBMoviesResponse
|
|
||||||
// @Router /bridge/tmdb/movie/top_rated [get]
|
|
||||||
func GetTMDBTopRatedMovies(c *gin.Context) {
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
|
|
||||||
movies, err := tmdbClient.GetTopRated(page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем полные URL для изображений
|
|
||||||
for i := range movies.Results {
|
|
||||||
if movies.Results[i].PosterPath != "" {
|
|
||||||
movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500")
|
|
||||||
}
|
|
||||||
if movies.Results[i].BackdropPath != "" {
|
|
||||||
movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, movies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTMDBUpcomingMovies возвращает список предстоящих фильмов из TMDB
|
|
||||||
// @Summary Get TMDB upcoming movies
|
|
||||||
// @Description Get a list of upcoming movies directly from TMDB
|
|
||||||
// @Tags tmdb
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param page query int false "Page number (default: 1)"
|
|
||||||
// @Success 200 {object} TMDBMoviesResponse
|
|
||||||
// @Router /bridge/tmdb/movie/upcoming [get]
|
|
||||||
func GetTMDBUpcomingMovies(c *gin.Context) {
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
|
|
||||||
movies, err := tmdbClient.GetUpcoming(page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем полные URL для изображений
|
|
||||||
for i := range movies.Results {
|
|
||||||
if movies.Results[i].PosterPath != "" {
|
|
||||||
movies.Results[i].PosterPath = tmdbClient.GetImageURL(movies.Results[i].PosterPath, "w500")
|
|
||||||
}
|
|
||||||
if movies.Results[i].BackdropPath != "" {
|
|
||||||
movies.Results[i].BackdropPath = tmdbClient.GetImageURL(movies.Results[i].BackdropPath, "w1280")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, movies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchTMDBMovies ищет фильмы в TMDB
|
|
||||||
// @Summary Search TMDB movies
|
|
||||||
// @Description Search for movies directly in TMDB
|
|
||||||
// @Tags tmdb
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param query query string true "Search query"
|
|
||||||
// @Param page query int false "Page number (default: 1)"
|
|
||||||
// @Success 200 {object} tmdb.MoviesResponse
|
|
||||||
// @Router /bridge/tmdb/search/movie [get]
|
|
||||||
func SearchTMDBMovies(c *gin.Context) {
|
|
||||||
query := c.Query("query")
|
|
||||||
if query == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
movies, err := tmdbClient.SearchMovies(query, page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, movies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchTMDBTV ищет сериалы в TMDB
|
|
||||||
// @Summary Search TMDB TV shows
|
|
||||||
// @Description Search for TV shows directly in TMDB
|
|
||||||
// @Tags tmdb
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param query query string true "Search query"
|
|
||||||
// @Param page query int false "Page number (default: 1)"
|
|
||||||
// @Success 200 {object} tmdb.TVSearchResults
|
|
||||||
// @Router /bridge/tmdb/search/tv [get]
|
|
||||||
func SearchTMDBTV(c *gin.Context) {
|
|
||||||
query := c.Query("query")
|
|
||||||
if query == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
tv, err := tmdbClient.SearchTV(query, page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, tv)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiscoverMovies возвращает список фильмов по фильтрам
|
|
||||||
// @Summary Discover movies
|
|
||||||
// @Description Get a list of movies based on filters
|
|
||||||
// @Tags tmdb
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param page query int false "Page number (default: 1)"
|
|
||||||
// @Success 200 {object} TMDBMoviesResponse
|
|
||||||
// @Router /bridge/tmdb/discover/movie [get]
|
|
||||||
func DiscoverMovies(c *gin.Context) {
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
movies, err := tmdbClient.DiscoverMovies(page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, movies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiscoverTV возвращает список сериалов по фильтрам
|
|
||||||
// @Summary Discover TV shows
|
|
||||||
// @Description Get a list of TV shows based on filters
|
|
||||||
// @Tags tmdb
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param page query int false "Page number (default: 1)"
|
|
||||||
// @Success 200 {object} TMDBMoviesResponse
|
|
||||||
// @Router /bridge/tmdb/discover/tv [get]
|
|
||||||
func DiscoverTV(c *gin.Context) {
|
|
||||||
page := c.DefaultQuery("page", "1")
|
|
||||||
shows, err := tmdbClient.DiscoverTV(page)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, shows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTMDBMovieExternalIDs возвращает внешние идентификаторы фильма
|
|
||||||
// @Summary Get TMDB movie external IDs
|
|
||||||
// @Description Get external IDs (IMDb, Facebook, Instagram, Twitter) for a specific movie
|
|
||||||
// @Tags tmdb
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path int true "Movie ID"
|
|
||||||
// @Success 200 {object} tmdb.ExternalIDs
|
|
||||||
// @Router /bridge/tmdb/movie/{id}/external_ids [get]
|
|
||||||
func GetTMDBMovieExternalIDs(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
|
|
||||||
externalIDs, err := tmdbClient.GetMovieExternalIDs(id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, externalIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTMDBTVExternalIDs возвращает внешние идентификаторы сериала
|
|
||||||
// @Summary Get TMDB TV show external IDs
|
|
||||||
// @Description Get external IDs (IMDb, Facebook, Instagram, Twitter) for a specific TV show
|
|
||||||
// @Tags tmdb
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path int true "TV Show ID"
|
|
||||||
// @Success 200 {object} tmdb.ExternalIDs
|
|
||||||
// @Router /bridge/tmdb/tv/{id}/external_ids [get]
|
|
||||||
func GetTMDBTVExternalIDs(c *gin.Context) {
|
|
||||||
id := c.Param("id")
|
|
||||||
|
|
||||||
externalIDs, err := tmdbClient.GetTVExternalIDs(id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, externalIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HealthCheck godoc
|
|
||||||
// @Summary Проверка работоспособности API
|
|
||||||
// @Description Проверяет, что API работает
|
|
||||||
// @Tags health
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} map[string]string
|
|
||||||
// @Router /health [get]
|
|
||||||
func HealthCheck(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"status": "ok",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitTMDBClientWithProxy инициализирует TMDB клиент с прокси
|
|
||||||
func InitTMDBClientWithProxy(apiKey string, proxyAddr string) error {
|
|
||||||
tmdbClient = tmdb.NewClient(apiKey)
|
|
||||||
return tmdbClient.SetSOCKS5Proxy(proxyAddr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin handlers
|
|
||||||
|
|
||||||
// GetAdminMovies возвращает список фильмов для админа
|
|
||||||
func GetAdminMovies(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Admin movies list"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToggleMovieVisibility переключает видимость фильма
|
|
||||||
func ToggleMovieVisibility(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Movie visibility toggled"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUsers возвращает список пользователей
|
|
||||||
func GetUsers(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Users list"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateUser создает нового пользователя
|
|
||||||
func CreateUser(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "User created"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToggleAdmin переключает права администратора
|
|
||||||
func ToggleAdmin(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Admin status toggled"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendVerification отправляет код верификации
|
|
||||||
func SendVerification(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Verification code sent"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyCode проверяет код верификации
|
|
||||||
func VerifyCode(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Code verified"})
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"neomovies-api/internal/tmdb"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
tmdbClient *tmdb.Client
|
|
||||||
)
|
|
||||||
|
|
||||||
// InitTMDBClient инициализирует TMDB клиент
|
|
||||||
func InitTMDBClient(apiKey string) {
|
|
||||||
tmdbClient = tmdb.NewClient(apiKey)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
// Genre представляет жанр фильма
|
|
||||||
type Genre struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Movie представляет базовую информацию о фильме
|
|
||||||
type Movie struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Overview string `json:"overview"`
|
|
||||||
PosterPath *string `json:"poster_path"`
|
|
||||||
BackdropPath *string `json:"backdrop_path"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
VoteAverage float64 `json:"vote_average"`
|
|
||||||
Genres []Genre `json:"genres"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MovieDetails представляет детальную информацию о фильме
|
|
||||||
type MovieDetails struct {
|
|
||||||
Movie
|
|
||||||
Runtime int `json:"runtime"`
|
|
||||||
Tagline string `json:"tagline"`
|
|
||||||
Budget int `json:"budget"`
|
|
||||||
Revenue int `json:"revenue"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoviesResponse представляет ответ со списком фильмов
|
|
||||||
type MoviesResponse struct {
|
|
||||||
Page int `json:"page"`
|
|
||||||
TotalPages int `json:"total_pages"`
|
|
||||||
TotalResults int `json:"total_results"`
|
|
||||||
Results []Movie `json:"results"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TMDBMoviesResponse представляет ответ со списком фильмов от TMDB API
|
|
||||||
type TMDBMoviesResponse struct {
|
|
||||||
Page int `json:"page"`
|
|
||||||
TotalPages int `json:"total_pages"`
|
|
||||||
TotalResults int `json:"total_results"`
|
|
||||||
Results []Movie `json:"results"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchResponse представляет ответ на поисковый запрос
|
|
||||||
type SearchResponse struct {
|
|
||||||
Page int `json:"page"`
|
|
||||||
TotalPages int `json:"total_pages"`
|
|
||||||
TotalResults int `json:"total_results"`
|
|
||||||
Results []MovieResponse `json:"results"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MovieResponse представляет информацию о фильме в ответе API
|
|
||||||
type MovieResponse struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Overview string `json:"overview"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
VoteAverage float64 `json:"vote_average"`
|
|
||||||
PosterPath string `json:"poster_path"`
|
|
||||||
BackdropPath string `json:"backdrop_path"`
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// formatDate форматирует дату в более читаемый формат
|
|
||||||
func formatDate(date string) string {
|
|
||||||
if date == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Парсим дату из формата YYYY-MM-DD
|
|
||||||
t, err := time.Parse("2006-01-02", date)
|
|
||||||
if err != nil {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
|
|
||||||
// Форматируем дату в русском стиле
|
|
||||||
months := []string{
|
|
||||||
"января", "февраля", "марта", "апреля", "мая", "июня",
|
|
||||||
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.Format("2") + " " + months[t.Month()-1] + " " + t.Format("2006")
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
package tmdb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"path"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
baseURL = "https://api.themoviedb.org/3"
|
|
||||||
imageBaseURL = "https://image.tmdb.org/t/p"
|
|
||||||
googleDNS = "8.8.8.8:53" // Google Public DNS
|
|
||||||
cloudflareDNS = "1.1.1.1:53" // Cloudflare DNS
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client представляет клиент для работы с TMDB API
|
|
||||||
type Client struct {
|
|
||||||
apiKey string
|
|
||||||
httpClient *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewClient создает новый клиент TMDB API с кастомным DNS
|
|
||||||
func NewClient(apiKey string) *Client {
|
|
||||||
// Создаем кастомный DNS резолвер с двумя DNS серверами
|
|
||||||
dialer := &net.Dialer{
|
|
||||||
Timeout: 5 * time.Second,
|
|
||||||
Resolver: &net.Resolver{
|
|
||||||
PreferGo: true,
|
|
||||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
||||||
// Пробуем сначала Google DNS
|
|
||||||
d := net.Dialer{Timeout: 5 * time.Second}
|
|
||||||
conn, err := d.DialContext(ctx, "udp", googleDNS)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to connect to Google DNS, trying Cloudflare: %v", err)
|
|
||||||
// Если Google DNS не отвечает, пробуем Cloudflare
|
|
||||||
return d.DialContext(ctx, "udp", cloudflareDNS)
|
|
||||||
}
|
|
||||||
return conn, nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем транспорт с кастомным диалером
|
|
||||||
transport := &http.Transport{
|
|
||||||
DialContext: dialer.DialContext,
|
|
||||||
TLSHandshakeTimeout: 5 * time.Second,
|
|
||||||
ResponseHeaderTimeout: 10 * time.Second,
|
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
|
||||||
MaxIdleConns: 100,
|
|
||||||
IdleConnTimeout: 90 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &Client{
|
|
||||||
apiKey: apiKey,
|
|
||||||
httpClient: &http.Client{
|
|
||||||
Transport: transport,
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем работу DNS и API
|
|
||||||
log.Println("Testing DNS resolution and TMDB API access...")
|
|
||||||
|
|
||||||
// Тест 1: Проверяем резолвинг через DNS
|
|
||||||
ips, err := net.LookupIP("api.themoviedb.org")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: DNS lookup failed: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("Successfully resolved api.themoviedb.org to: %v", ips)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Тест 2: Проверяем наш IP
|
|
||||||
resp, err := client.httpClient.Get("https://ipinfo.io/json")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: Failed to check our IP: %v", err)
|
|
||||||
} else {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
var ipInfo struct {
|
|
||||||
IP string `json:"ip"`
|
|
||||||
City string `json:"city"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
Org string `json:"org"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&ipInfo); err != nil {
|
|
||||||
log.Printf("Warning: Failed to decode IP info: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("Our IP info: IP=%s, City=%s, Country=%s, Org=%s",
|
|
||||||
ipInfo.IP, ipInfo.City, ipInfo.Country, ipInfo.Org)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Тест 3: Проверяем доступ к TMDB API
|
|
||||||
testURL := fmt.Sprintf("%s/movie/popular?api_key=%s", baseURL, apiKey)
|
|
||||||
resp, err = client.httpClient.Get(testURL)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: TMDB API test failed: %v", err)
|
|
||||||
} else {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
log.Println("Successfully connected to TMDB API!")
|
|
||||||
} else {
|
|
||||||
log.Printf("Warning: TMDB API returned status code: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSOCKS5Proxy устанавливает SOCKS5 прокси для клиента
|
|
||||||
func (c *Client) SetSOCKS5Proxy(proxyAddr string) error {
|
|
||||||
return fmt.Errorf("proxy support has been removed in favor of custom DNS resolvers")
|
|
||||||
}
|
|
||||||
|
|
||||||
// makeRequest выполняет HTTP запрос к TMDB API
|
|
||||||
func (c *Client) makeRequest(method, endpoint string, params url.Values) ([]byte, error) {
|
|
||||||
// Создаем URL
|
|
||||||
u, err := url.Parse(baseURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse base URL: %v", err)
|
|
||||||
}
|
|
||||||
u.Path = path.Join(u.Path, endpoint)
|
|
||||||
if params == nil {
|
|
||||||
params = url.Values{}
|
|
||||||
}
|
|
||||||
u.RawQuery = params.Encode()
|
|
||||||
|
|
||||||
// Создаем запрос
|
|
||||||
req, err := http.NewRequest(method, u.String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем заголовок авторизации
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json;charset=utf-8")
|
|
||||||
|
|
||||||
log.Printf("Making request to TMDB: %s %s", method, u.String())
|
|
||||||
|
|
||||||
// Выполняем запрос
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to make request: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Проверяем статус ответа
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return nil, fmt.Errorf("TMDB API error: status=%d body=%s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Читаем тело ответа
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response body: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return body, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetImageURL возвращает полный URL изображения
|
|
||||||
func (c *Client) GetImageURL(path string, size string) string {
|
|
||||||
if path == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s/%s%s", imageBaseURL, size, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPopular получает список популярных фильмов
|
|
||||||
func (c *Client) GetPopular(page string) (*MoviesResponse, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Set("page", page)
|
|
||||||
|
|
||||||
body, err := c.makeRequest(http.MethodGet, "movie/popular", params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response MoviesResponse
|
|
||||||
if err := json.Unmarshal(body, &response); err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMovie получает информацию о конкретном фильме
|
|
||||||
func (c *Client) GetMovie(id string) (*MovieDetails, error) {
|
|
||||||
body, err := c.makeRequest(http.MethodGet, fmt.Sprintf("movie/%s", id), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var movie MovieDetails
|
|
||||||
if err := json.Unmarshal(body, &movie); err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &movie, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchMovies ищет фильмы по запросу с поддержкой русского языка
|
|
||||||
func (c *Client) SearchMovies(query string, page string) (*MoviesResponse, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Set("query", query)
|
|
||||||
params.Set("page", page)
|
|
||||||
params.Set("language", "ru-RU") // Добавляем русский язык
|
|
||||||
params.Set("region", "RU") // Добавляем русский регион
|
|
||||||
params.Set("include_adult", "false") // Исключаем взрослый контент
|
|
||||||
|
|
||||||
body, err := c.makeRequest(http.MethodGet, "search/movie", params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response MoviesResponse
|
|
||||||
if err := json.Unmarshal(body, &response); err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Фильтруем результаты
|
|
||||||
filteredResults := make([]Movie, 0)
|
|
||||||
for _, movie := range response.Results {
|
|
||||||
// Проверяем, что у фильма есть постер и описание
|
|
||||||
if movie.PosterPath != "" && movie.Overview != "" {
|
|
||||||
// Проверяем, что рейтинг больше 0
|
|
||||||
if movie.VoteAverage > 0 {
|
|
||||||
filteredResults = append(filteredResults, movie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем результаты
|
|
||||||
response.Results = filteredResults
|
|
||||||
response.TotalResults = len(filteredResults)
|
|
||||||
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTopRated получает список лучших фильмов
|
|
||||||
func (c *Client) GetTopRated(page string) (*MoviesResponse, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Set("page", page)
|
|
||||||
|
|
||||||
body, err := c.makeRequest(http.MethodGet, "movie/top_rated", params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response MoviesResponse
|
|
||||||
if err := json.Unmarshal(body, &response); err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUpcoming получает список предстоящих фильмов
|
|
||||||
func (c *Client) GetUpcoming(page string) (*MoviesResponse, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Set("page", page)
|
|
||||||
|
|
||||||
body, err := c.makeRequest(http.MethodGet, "movie/upcoming", params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response MoviesResponse
|
|
||||||
if err := json.Unmarshal(body, &response); err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiscoverMovies получает список фильмов по фильтрам
|
|
||||||
func (c *Client) DiscoverMovies(page string) (*MoviesResponse, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Set("page", page)
|
|
||||||
|
|
||||||
body, err := c.makeRequest(http.MethodGet, "discover/movie", params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response MoviesResponse
|
|
||||||
if err := json.Unmarshal(body, &response); err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiscoverTV получает список сериалов по фильтрам
|
|
||||||
func (c *Client) DiscoverTV(page string) (*MoviesResponse, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Set("page", page)
|
|
||||||
|
|
||||||
body, err := c.makeRequest(http.MethodGet, "discover/tv", params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var response MoviesResponse
|
|
||||||
if err := json.Unmarshal(body, &response); err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExternalIDs содержит внешние идентификаторы фильма/сериала
|
|
||||||
type ExternalIDs struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
IMDbID string `json:"imdb_id"`
|
|
||||||
FacebookID string `json:"facebook_id"`
|
|
||||||
InstagramID string `json:"instagram_id"`
|
|
||||||
TwitterID string `json:"twitter_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMovieExternalIDs возвращает внешние идентификаторы фильма
|
|
||||||
func (c *Client) GetMovieExternalIDs(id string) (*ExternalIDs, error) {
|
|
||||||
body, err := c.makeRequest(http.MethodGet, fmt.Sprintf("movie/%s/external_ids", id), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var externalIDs ExternalIDs
|
|
||||||
if err := json.Unmarshal(body, &externalIDs); err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &externalIDs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTVExternalIDs возвращает внешние идентификаторы сериала
|
|
||||||
func (c *Client) GetTVExternalIDs(id string) (*ExternalIDs, error) {
|
|
||||||
body, err := c.makeRequest(http.MethodGet, fmt.Sprintf("tv/%s/external_ids", id), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var externalIDs ExternalIDs
|
|
||||||
if err := json.Unmarshal(body, &externalIDs); err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &externalIDs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TVSearchResults содержит результаты поиска сериалов
|
|
||||||
type TVSearchResults struct {
|
|
||||||
Page int `json:"page"`
|
|
||||||
TotalResults int `json:"total_results"`
|
|
||||||
TotalPages int `json:"total_pages"`
|
|
||||||
Results []TV `json:"results"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TV содержит информацию о сериале
|
|
||||||
type TV struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
OriginalName string `json:"original_name"`
|
|
||||||
Overview string `json:"overview"`
|
|
||||||
FirstAirDate string `json:"first_air_date"`
|
|
||||||
PosterPath string `json:"poster_path"`
|
|
||||||
BackdropPath string `json:"backdrop_path"`
|
|
||||||
VoteAverage float64 `json:"vote_average"`
|
|
||||||
VoteCount int `json:"vote_count"`
|
|
||||||
Popularity float64 `json:"popularity"`
|
|
||||||
OriginalLanguage string `json:"original_language"`
|
|
||||||
GenreIDs []int `json:"genre_ids"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchTV ищет сериалы в TMDB
|
|
||||||
func (c *Client) SearchTV(query string, page string) (*TVSearchResults, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Set("query", query)
|
|
||||||
params.Set("page", page)
|
|
||||||
|
|
||||||
body, err := c.makeRequest(http.MethodGet, "search/tv", params)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var results TVSearchResults
|
|
||||||
if err := json.Unmarshal(body, &results); err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &results, nil
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package tmdb
|
|
||||||
|
|
||||||
// MoviesResponse представляет ответ от TMDB API со списком фильмов
|
|
||||||
type MoviesResponse struct {
|
|
||||||
Page int `json:"page"`
|
|
||||||
Results []Movie `json:"results"`
|
|
||||||
TotalPages int `json:"total_pages"`
|
|
||||||
TotalResults int `json:"total_results"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Movie представляет информацию о фильме
|
|
||||||
type Movie struct {
|
|
||||||
Adult bool `json:"adult"`
|
|
||||||
BackdropPath string `json:"backdrop_path"`
|
|
||||||
GenreIDs []int `json:"genre_ids"`
|
|
||||||
ID int `json:"id"`
|
|
||||||
OriginalLanguage string `json:"original_language"`
|
|
||||||
OriginalTitle string `json:"original_title"`
|
|
||||||
Overview string `json:"overview"`
|
|
||||||
Popularity float64 `json:"popularity"`
|
|
||||||
PosterPath string `json:"poster_path"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Video bool `json:"video"`
|
|
||||||
VoteAverage float64 `json:"vote_average"`
|
|
||||||
VoteCount int `json:"vote_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Genre представляет жанр фильма
|
|
||||||
type Genre struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collection представляет коллекцию фильмов
|
|
||||||
type Collection struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
PosterPath string `json:"poster_path"`
|
|
||||||
BackdropPath string `json:"backdrop_path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProductionCompany представляет компанию-производителя
|
|
||||||
type ProductionCompany struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
LogoPath string `json:"logo_path"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Country string `json:"origin_country"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MovieDetails представляет детальную информацию о фильме
|
|
||||||
type MovieDetails struct {
|
|
||||||
Adult bool `json:"adult"`
|
|
||||||
BackdropPath string `json:"backdrop_path"`
|
|
||||||
BelongsToCollection *Collection `json:"belongs_to_collection"`
|
|
||||||
Budget int `json:"budget"`
|
|
||||||
Genres []Genre `json:"genres"`
|
|
||||||
Homepage string `json:"homepage"`
|
|
||||||
ID int `json:"id"`
|
|
||||||
IMDbID string `json:"imdb_id"`
|
|
||||||
OriginalLanguage string `json:"original_language"`
|
|
||||||
OriginalTitle string `json:"original_title"`
|
|
||||||
Overview string `json:"overview"`
|
|
||||||
Popularity float64 `json:"popularity"`
|
|
||||||
PosterPath string `json:"poster_path"`
|
|
||||||
ProductionCompanies []ProductionCompany `json:"production_companies"`
|
|
||||||
ReleaseDate string `json:"release_date"`
|
|
||||||
Revenue int `json:"revenue"`
|
|
||||||
Runtime int `json:"runtime"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Tagline string `json:"tagline"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Video bool `json:"video"`
|
|
||||||
VoteAverage float64 `json:"vote_average"`
|
|
||||||
VoteCount int `json:"vote_count"`
|
|
||||||
}
|
|
||||||
278
main.go
278
main.go
@@ -1,125 +1,187 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"neomovies-api/internal/api"
|
"github.com/gorilla/handlers"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
"neomovies-api/pkg/config"
|
||||||
"github.com/gin-gonic/gin"
|
"neomovies-api/pkg/database"
|
||||||
swaggerFiles "github.com/swaggo/files"
|
appHandlers "neomovies-api/pkg/handlers"
|
||||||
ginSwagger "github.com/swaggo/gin-swagger"
|
"neomovies-api/pkg/middleware"
|
||||||
|
"neomovies-api/pkg/monitor"
|
||||||
_ "neomovies-api/docs"
|
"neomovies-api/pkg/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @title Neo Movies API
|
|
||||||
// @version 1.0
|
|
||||||
// @description API для работы с фильмами
|
|
||||||
// @host localhost:8080
|
|
||||||
// @BasePath /
|
|
||||||
func main() {
|
func main() {
|
||||||
// Устанавливаем переменные окружения
|
if err := godotenv.Load(); err != nil {
|
||||||
os.Setenv("GIN_MODE", "debug")
|
_ = err
|
||||||
os.Setenv("PORT", "8080")
|
|
||||||
os.Setenv("TMDB_ACCESS_TOKEN", "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI4ZmU3ODhlYmI5ZDAwNjZiNjQ2MWZhNzk5M2MyMzcxYiIsIm5iZiI6MTcyMzQwMTM3My4yMDgsInN1YiI6IjY2YjkwNDlkNzU4ZDQxOTQwYzA3NjlhNSIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.x50tvcWDdBTEhtwRb3dE7aEe9qu4sXV_qOjLMn_Vmew")
|
|
||||||
|
|
||||||
// Инициализируем TMDB клиент с CommsOne DNS
|
|
||||||
log.Println("Initializing TMDB client with CommsOne DNS")
|
|
||||||
api.InitTMDBClient(os.Getenv("TMDB_ACCESS_TOKEN"))
|
|
||||||
|
|
||||||
// Устанавливаем режим Gin
|
|
||||||
gin.SetMode(os.Getenv("GIN_MODE"))
|
|
||||||
|
|
||||||
// Создаем роутер
|
|
||||||
r := gin.Default()
|
|
||||||
|
|
||||||
// Настраиваем CORS
|
|
||||||
r.Use(cors.New(cors.Config{
|
|
||||||
AllowOrigins: []string{"*"},
|
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
|
||||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Swagger документация
|
|
||||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
|
||||||
|
|
||||||
// Health check
|
|
||||||
r.GET("/health", api.HealthCheck)
|
|
||||||
|
|
||||||
// Movies API
|
|
||||||
movies := r.Group("/movies")
|
|
||||||
{
|
|
||||||
movies.GET("/popular", api.GetPopularMovies)
|
|
||||||
movies.GET("/search", api.SearchMovies)
|
|
||||||
movies.GET("/top-rated", api.GetTopRatedMovies)
|
|
||||||
movies.GET("/upcoming", api.GetUpcomingMovies)
|
|
||||||
movies.GET("/:id", api.GetMovie)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bridge API
|
cfg := config.New()
|
||||||
bridge := r.Group("/bridge")
|
|
||||||
{
|
db, err := database.Connect(cfg.MongoURI, cfg.MongoDBName)
|
||||||
// TMDB endpoints
|
if err != nil {
|
||||||
tmdb := bridge.Group("/tmdb")
|
fmt.Printf("❌ Failed to connect to database: %v\n", err)
|
||||||
{
|
os.Exit(1)
|
||||||
// Movie endpoints
|
}
|
||||||
movie := tmdb.Group("/movie")
|
defer database.Disconnect()
|
||||||
{
|
|
||||||
movie.GET("/popular", api.GetTMDBPopularMovies)
|
tmdbService := services.NewTMDBService(cfg.TMDBAccessToken)
|
||||||
movie.GET("/top_rated", api.GetTMDBTopRatedMovies)
|
emailService := services.NewEmailService(cfg)
|
||||||
movie.GET("/upcoming", api.GetTMDBUpcomingMovies)
|
authService := services.NewAuthService(db, cfg.JWTSecret, emailService, cfg.BaseURL, cfg.GoogleClientID, cfg.GoogleClientSecret, cfg.GoogleRedirectURL, cfg.FrontendURL)
|
||||||
movie.GET("/:id", api.GetTMDBMovie)
|
|
||||||
movie.GET("/:id/external_ids", api.GetTMDBMovieExternalIDs)
|
movieService := services.NewMovieService(db, tmdbService)
|
||||||
|
tvService := services.NewTVService(db, tmdbService)
|
||||||
|
favoritesService := services.NewFavoritesService(db, tmdbService)
|
||||||
|
torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey)
|
||||||
|
reactionsService := services.NewReactionsService(db)
|
||||||
|
|
||||||
|
authHandler := appHandlers.NewAuthHandler(authService)
|
||||||
|
movieHandler := appHandlers.NewMovieHandler(movieService)
|
||||||
|
tvHandler := appHandlers.NewTVHandler(tvService)
|
||||||
|
favoritesHandler := appHandlers.NewFavoritesHandler(favoritesService, cfg)
|
||||||
|
docsHandler := appHandlers.NewDocsHandler()
|
||||||
|
searchHandler := appHandlers.NewSearchHandler(tmdbService)
|
||||||
|
categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService)
|
||||||
|
playersHandler := appHandlers.NewPlayersHandler(cfg)
|
||||||
|
torrentsHandler := appHandlers.NewTorrentsHandler(torrentService, tmdbService)
|
||||||
|
reactionsHandler := appHandlers.NewReactionsHandler(reactionsService)
|
||||||
|
imagesHandler := appHandlers.NewImagesHandler()
|
||||||
|
|
||||||
|
r := mux.NewRouter()
|
||||||
|
|
||||||
|
r.HandleFunc("/", docsHandler.ServeDocs).Methods("GET")
|
||||||
|
r.HandleFunc("/openapi.json", docsHandler.GetOpenAPISpec).Methods("GET")
|
||||||
|
|
||||||
|
api := r.PathPrefix("/api/v1").Subrouter()
|
||||||
|
|
||||||
|
api.HandleFunc("/health", appHandlers.HealthCheck).Methods("GET")
|
||||||
|
api.HandleFunc("/auth/register", authHandler.Register).Methods("POST")
|
||||||
|
api.HandleFunc("/auth/login", authHandler.Login).Methods("POST")
|
||||||
|
api.HandleFunc("/auth/verify", authHandler.VerifyEmail).Methods("POST")
|
||||||
|
api.HandleFunc("/auth/resend-code", authHandler.ResendVerificationCode).Methods("POST")
|
||||||
|
api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET")
|
||||||
|
api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET")
|
||||||
|
api.HandleFunc("/auth/refresh", authHandler.RefreshToken).Methods("POST")
|
||||||
|
|
||||||
|
api.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
|
||||||
|
|
||||||
|
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
|
||||||
|
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET")
|
||||||
|
api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).Methods("GET")
|
||||||
|
|
||||||
|
api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
||||||
|
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
|
||||||
|
api.HandleFunc("/players/vibix/{imdb_id}", playersHandler.GetVibixPlayer).Methods("GET")
|
||||||
|
api.HandleFunc("/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("/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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search endpoints
|
port := cfg.Port
|
||||||
search := tmdb.Group("/search")
|
if port == "" {
|
||||||
{
|
port = "3000"
|
||||||
search.GET("/movie", api.SearchTMDBMovies)
|
|
||||||
search.GET("/tv", api.SearchTMDBTV)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TV endpoints
|
if err := http.ListenAndServe(":"+port, finalHandler); err != nil {
|
||||||
tv := tmdb.Group("/tv")
|
fmt.Printf("❌ Server failed to start: %v\n", err)
|
||||||
{
|
os.Exit(1)
|
||||||
tv.GET("/:id/external_ids", api.GetTMDBTVExternalIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discover endpoints
|
|
||||||
discover := tmdb.Group("/discover")
|
|
||||||
{
|
|
||||||
discover.GET("/movie", api.DiscoverMovies)
|
|
||||||
discover.GET("/tv", api.DiscoverTV)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin API
|
|
||||||
admin := r.Group("/admin")
|
|
||||||
{
|
|
||||||
// Movies endpoints
|
|
||||||
adminMovies := admin.Group("/movies")
|
|
||||||
{
|
|
||||||
adminMovies.GET("", api.GetAdminMovies)
|
|
||||||
adminMovies.POST("/toggle-visibility", api.ToggleMovieVisibility)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users endpoints
|
|
||||||
adminUsers := admin.Group("/users")
|
|
||||||
{
|
|
||||||
adminUsers.GET("", api.GetUsers)
|
|
||||||
adminUsers.POST("/create", api.CreateUser)
|
|
||||||
adminUsers.POST("/toggle-admin", api.ToggleAdmin)
|
|
||||||
adminUsers.POST("/send-verification", api.SendVerification)
|
|
||||||
adminUsers.POST("/verify-code", api.VerifyCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запускаем сервер
|
|
||||||
port := os.Getenv("PORT")
|
|
||||||
if err := r.Run(":" + port); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
pkg/config/config.go
Normal file
72
pkg/config/config.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
MongoURI string
|
||||||
|
MongoDBName string
|
||||||
|
TMDBAccessToken string
|
||||||
|
JWTSecret string
|
||||||
|
Port string
|
||||||
|
BaseURL string
|
||||||
|
NodeEnv string
|
||||||
|
GmailUser string
|
||||||
|
GmailPassword string
|
||||||
|
LumexURL string
|
||||||
|
AllohaToken string
|
||||||
|
RedAPIBaseURL string
|
||||||
|
RedAPIKey string
|
||||||
|
GoogleClientID string
|
||||||
|
GoogleClientSecret string
|
||||||
|
GoogleRedirectURL string
|
||||||
|
FrontendURL string
|
||||||
|
VibixHost string
|
||||||
|
VibixToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Config {
|
||||||
|
mongoURI := getMongoURI()
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
MongoURI: mongoURI,
|
||||||
|
MongoDBName: getEnv(EnvMongoDBName, DefaultMongoDBName),
|
||||||
|
TMDBAccessToken: getEnv(EnvTMDBAccessToken, ""),
|
||||||
|
JWTSecret: getEnv(EnvJWTSecret, DefaultJWTSecret),
|
||||||
|
Port: getEnv(EnvPort, DefaultPort),
|
||||||
|
BaseURL: getEnv(EnvBaseURL, DefaultBaseURL),
|
||||||
|
NodeEnv: getEnv(EnvNodeEnv, DefaultNodeEnv),
|
||||||
|
GmailUser: getEnv(EnvGmailUser, ""),
|
||||||
|
GmailPassword: getEnv(EnvGmailPassword, ""),
|
||||||
|
LumexURL: getEnv(EnvLumexURL, ""),
|
||||||
|
AllohaToken: getEnv(EnvAllohaToken, ""),
|
||||||
|
RedAPIBaseURL: getEnv(EnvRedAPIBaseURL, DefaultRedAPIBase),
|
||||||
|
RedAPIKey: getEnv(EnvRedAPIKey, ""),
|
||||||
|
GoogleClientID: getEnv(EnvGoogleClientID, ""),
|
||||||
|
GoogleClientSecret: getEnv(EnvGoogleClientSecret, ""),
|
||||||
|
GoogleRedirectURL: getEnv(EnvGoogleRedirectURL, ""),
|
||||||
|
FrontendURL: getEnv(EnvFrontendURL, ""),
|
||||||
|
VibixHost: getEnv(EnvVibixHost, DefaultVibixHost),
|
||||||
|
VibixToken: getEnv(EnvVibixToken, ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMongoURI() string {
|
||||||
|
for _, envVar := range []string{"MONGO_URI", "MONGODB_URI", "DATABASE_URL", "MONGO_URL"} {
|
||||||
|
if value := os.Getenv(envVar); value != "" {
|
||||||
|
log.Printf("DEBUG: Using %s for MongoDB connection", envVar)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("DEBUG: No MongoDB URI environment variable found")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
36
pkg/config/vars.go
Normal file
36
pkg/config/vars.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Environment variable keys
|
||||||
|
EnvTMDBAccessToken = "TMDB_ACCESS_TOKEN"
|
||||||
|
EnvJWTSecret = "JWT_SECRET"
|
||||||
|
EnvPort = "PORT"
|
||||||
|
EnvBaseURL = "BASE_URL"
|
||||||
|
EnvNodeEnv = "NODE_ENV"
|
||||||
|
EnvGmailUser = "GMAIL_USER"
|
||||||
|
EnvGmailPassword = "GMAIL_APP_PASSWORD"
|
||||||
|
EnvLumexURL = "LUMEX_URL"
|
||||||
|
EnvAllohaToken = "ALLOHA_TOKEN"
|
||||||
|
EnvRedAPIBaseURL = "REDAPI_BASE_URL"
|
||||||
|
EnvRedAPIKey = "REDAPI_KEY"
|
||||||
|
EnvMongoDBName = "MONGO_DB_NAME"
|
||||||
|
EnvGoogleClientID = "GOOGLE_CLIENT_ID"
|
||||||
|
EnvGoogleClientSecret = "GOOGLE_CLIENT_SECRET"
|
||||||
|
EnvGoogleRedirectURL = "GOOGLE_REDIRECT_URL"
|
||||||
|
EnvFrontendURL = "FRONTEND_URL"
|
||||||
|
EnvVibixHost = "VIBIX_HOST"
|
||||||
|
EnvVibixToken = "VIBIX_TOKEN"
|
||||||
|
|
||||||
|
// Default values
|
||||||
|
DefaultJWTSecret = "your-secret-key"
|
||||||
|
DefaultPort = "3000"
|
||||||
|
DefaultBaseURL = "http://localhost:3000"
|
||||||
|
DefaultNodeEnv = "development"
|
||||||
|
DefaultRedAPIBase = "http://redapi.cfhttp.top"
|
||||||
|
DefaultMongoDBName = "database"
|
||||||
|
DefaultVibixHost = "https://vibix.org"
|
||||||
|
|
||||||
|
// Static constants
|
||||||
|
TMDBImageBaseURL = "https://image.tmdb.org/t/p"
|
||||||
|
CubAPIBaseURL = "https://cub.rip/api"
|
||||||
|
)
|
||||||
41
pkg/database/connection.go
Normal file
41
pkg/database/connection.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
var client *mongo.Client
|
||||||
|
|
||||||
|
func Connect(uri, dbName string) (*mongo.Database, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
client, err = mongo.Connect(ctx, options.Client().ApplyURI(uri))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = client.Ping(ctx, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Database(dbName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Disconnect() error {
|
||||||
|
if client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return client.Disconnect(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClient() *mongo.Client { return client }
|
||||||
309
pkg/handlers/auth.go
Normal file
309
pkg/handlers/auth.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/middleware"
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
"neomovies-api/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
authService *services.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
||||||
|
return &AuthHandler{authService: authService}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.RegisterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.authService.Register(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "User registered successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию о клиенте для refresh токена
|
||||||
|
userAgent := r.Header.Get("User-Agent")
|
||||||
|
ipAddress := r.RemoteAddr
|
||||||
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
ipAddress = forwarded
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.authService.LoginWithTokens(req, userAgent, ipAddress)
|
||||||
|
if err != nil {
|
||||||
|
statusCode := http.StatusBadRequest
|
||||||
|
if err.Error() == "Account not activated. Please verify your email." {
|
||||||
|
statusCode = http.StatusForbidden
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), statusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: response, Message: "Login successful"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state := generateState()
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, HttpOnly: true, Path: "/", Expires: time.Now().Add(10 * time.Minute)})
|
||||||
|
url, err := h.authService.GetGoogleLoginURL(state)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, url, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
state := q.Get("state")
|
||||||
|
code := q.Get("code")
|
||||||
|
preferJSON := q.Get("response") == "json" || strings.Contains(r.Header.Get("Accept"), "application/json")
|
||||||
|
cookie, _ := r.Cookie("oauth_state")
|
||||||
|
if cookie == nil || cookie.Value != state || code == "" {
|
||||||
|
if preferJSON {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: "invalid oauth state"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectURL, ok := h.authService.BuildFrontendRedirect("", "invalid_state")
|
||||||
|
if ok {
|
||||||
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "invalid oauth state", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.authService.HandleGoogleCallback(r.Context(), code)
|
||||||
|
if err != nil {
|
||||||
|
if preferJSON {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{Success: false, Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectURL, ok := h.authService.BuildFrontendRedirect("", "auth_failed")
|
||||||
|
if ok {
|
||||||
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if preferJSON {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURL, ok := h.authService.BuildFrontendRedirect(resp.Token, "")
|
||||||
|
if ok {
|
||||||
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: resp, Message: "Login successful"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.authService.GetUserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(updates, "password")
|
||||||
|
delete(updates, "email")
|
||||||
|
delete(updates, "_id")
|
||||||
|
delete(updates, "created_at")
|
||||||
|
|
||||||
|
user, err := h.authService.UpdateUser(userID, bson.M(updates))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to update user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Data: user, Message: "Profile updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.authService.DeleteAccount(r.Context(), userID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{Success: true, Message: "Account deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.VerifyEmailRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.authService.VerifyEmail(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) ResendVerificationCode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.ResendCodeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.authService.ResendVerificationCode(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken refreshes an access token using a refresh token
|
||||||
|
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.RefreshTokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию о клиенте
|
||||||
|
userAgent := r.Header.Get("User-Agent")
|
||||||
|
ipAddress := r.RemoteAddr
|
||||||
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
ipAddress = forwarded
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenPair, err := h.authService.RefreshAccessToken(req.RefreshToken, userAgent, ipAddress)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: tokenPair,
|
||||||
|
Message: "Token refreshed successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeRefreshToken revokes a specific refresh token
|
||||||
|
func (h *AuthHandler) RevokeRefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.RefreshTokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.authService.RevokeRefreshToken(userID, req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "Refresh token revoked successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeAllRefreshTokens revokes all refresh tokens for the current user
|
||||||
|
func (h *AuthHandler) RevokeAllRefreshTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.authService.RevokeAllRefreshTokens(userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Message: "All refresh tokens revoked successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpers
|
||||||
|
func generateState() string { return uuidNew() }
|
||||||
7
pkg/handlers/auth_helpers.go
Normal file
7
pkg/handlers/auth_helpers.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func uuidNew() string { return uuid.New().String() }
|
||||||
122
pkg/handlers/categories.go
Normal file
122
pkg/handlers/categories.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
"neomovies-api/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CategoriesHandler struct {
|
||||||
|
tmdbService *services.TMDBService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCategoriesHandler(tmdbService *services.TMDBService) *CategoriesHandler {
|
||||||
|
return &CategoriesHandler{
|
||||||
|
tmdbService: tmdbService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CategoriesHandler) GetCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Получаем все жанры
|
||||||
|
genresResponse, err := h.tmdbService.GetAllGenres()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Преобразуем жанры в категории
|
||||||
|
var categories []Category
|
||||||
|
for _, genre := range genresResponse.Genres {
|
||||||
|
slug := generateSlug(genre.Name)
|
||||||
|
categories = append(categories, Category{
|
||||||
|
ID: genre.ID,
|
||||||
|
Name: genre.Name,
|
||||||
|
Slug: slug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: categories,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *CategoriesHandler) GetMediaByCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
categoryID, err := strconv.Atoi(vars["id"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid category ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := getIntQuery(r, "page", 1)
|
||||||
|
language := r.URL.Query().Get("language")
|
||||||
|
if language == "" {
|
||||||
|
language = "ru-RU"
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType := r.URL.Query().Get("type")
|
||||||
|
if mediaType == "" {
|
||||||
|
mediaType = "movie" // По умолчанию фильмы для обратной совместимости
|
||||||
|
}
|
||||||
|
|
||||||
|
if mediaType != "movie" && mediaType != "tv" {
|
||||||
|
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var data interface{}
|
||||||
|
var err2 error
|
||||||
|
|
||||||
|
if mediaType == "movie" {
|
||||||
|
// Используем discover API для получения фильмов по жанру
|
||||||
|
data, err2 = h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
|
||||||
|
} else {
|
||||||
|
// Используем discover API для получения сериалов по жанру
|
||||||
|
data, err2 = h.tmdbService.DiscoverTVByGenre(categoryID, page, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err2 != nil {
|
||||||
|
http.Error(w, err2.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: data,
|
||||||
|
Message: "Media retrieved successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Старый метод для обратной совместимости
|
||||||
|
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Просто перенаправляем на новый метод
|
||||||
|
h.GetMediaByCategory(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSlug(name string) string {
|
||||||
|
// Простая функция для создания slug из названия
|
||||||
|
// В реальном проекте стоит использовать более сложную логику
|
||||||
|
result := ""
|
||||||
|
for _, char := range name {
|
||||||
|
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
|
||||||
|
result += string(char)
|
||||||
|
} else if char == ' ' {
|
||||||
|
result += "-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
1875
pkg/handlers/docs.go
Normal file
1875
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
|
||||||
|
}
|
||||||
|
}
|
||||||
225
pkg/handlers/movie.go
Normal file
225
pkg/handlers/movie.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
"neomovies-api/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MovieHandler struct {
|
||||||
|
movieService *services.MovieService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMovieHandler(movieService *services.MovieService) *MovieHandler {
|
||||||
|
return &MovieHandler{
|
||||||
|
movieService: movieService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MovieHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query().Get("query")
|
||||||
|
if query == "" {
|
||||||
|
http.Error(w, "Query parameter is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := getIntQuery(r, "page", 1)
|
||||||
|
language := 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)
|
||||||
|
id, err := strconv.Atoi(vars["id"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid movie ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
language := GetLanguage(r)
|
||||||
|
|
||||||
|
movie, err := h.movieService.GetByID(id, language)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: movie,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MovieHandler) Popular(w http.ResponseWriter, r *http.Request) {
|
||||||
|
page := getIntQuery(r, "page", 1)
|
||||||
|
language := 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
|
||||||
|
}
|
||||||
605
pkg/handlers/players.go
Normal file
605
pkg/handlers/players.go
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"neomovies-api/pkg/config"
|
||||||
|
"neomovies-api/pkg/players"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PlayersHandler struct {
|
||||||
|
config *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPlayersHandler(cfg *config.Config) *PlayersHandler {
|
||||||
|
return &PlayersHandler{
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PlayersHandler) GetAllohaPlayer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("GetAllohaPlayer called: %s %s", r.Method, r.URL.Path)
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
log.Printf("Route vars: %+v", vars)
|
||||||
|
|
||||||
|
imdbID := vars["imdb_id"]
|
||||||
|
if imdbID == "" {
|
||||||
|
log.Printf("Error: imdb_id is empty")
|
||||||
|
http.Error(w, "imdb_id path param is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Processing imdb_id: %s", imdbID)
|
||||||
|
|
||||||
|
if h.config.AllohaToken == "" {
|
||||||
|
log.Printf("Error: ALLOHA_TOKEN is missing")
|
||||||
|
http.Error(w, "Server misconfiguration: ALLOHA_TOKEN missing", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idParam := fmt.Sprintf("imdb=%s", url.QueryEscape(imdbID))
|
||||||
|
apiURL := fmt.Sprintf("https://api.alloha.tv/?token=%s&%s", h.config.AllohaToken, idParam)
|
||||||
|
log.Printf("Calling Alloha API: %s", apiURL)
|
||||||
|
|
||||||
|
resp, err := http.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error calling Alloha API: %v", err)
|
||||||
|
http.Error(w, "Failed to fetch from Alloha API", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
log.Printf("Alloha API response status: %d", resp.StatusCode)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
http.Error(w, fmt.Sprintf("Alloha API error: %d", resp.StatusCode), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error reading Alloha response: %v", err)
|
||||||
|
http.Error(w, "Failed to read Alloha response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Alloha API response body: %s", string(body))
|
||||||
|
|
||||||
|
var allohaResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data struct {
|
||||||
|
Iframe string `json:"iframe"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &allohaResponse); err != nil {
|
||||||
|
log.Printf("Error unmarshaling JSON: %v", err)
|
||||||
|
http.Error(w, "Invalid JSON from Alloha", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if allohaResponse.Status != "success" || allohaResponse.Data.Iframe == "" {
|
||||||
|
log.Printf("Video not found or empty iframe")
|
||||||
|
http.Error(w, "Video not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем параметры для сериалов
|
||||||
|
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 imdb_id: %s", imdbID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PlayersHandler) GetLumexPlayer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("GetLumexPlayer called: %s %s", r.Method, r.URL.Path)
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
log.Printf("Route vars: %+v", vars)
|
||||||
|
|
||||||
|
imdbID := vars["imdb_id"]
|
||||||
|
if imdbID == "" {
|
||||||
|
log.Printf("Error: imdb_id is empty")
|
||||||
|
http.Error(w, "imdb_id path param is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Processing imdb_id: %s", imdbID)
|
||||||
|
|
||||||
|
if h.config.LumexURL == "" {
|
||||||
|
log.Printf("Error: LUMEX_URL is missing")
|
||||||
|
http.Error(w, "Server misconfiguration: LUMEX_URL missing", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lumex использует только IMDb ID без season/episode
|
||||||
|
playerURL := fmt.Sprintf("%s?imdb_id=%s", h.config.LumexURL, imdbID)
|
||||||
|
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 imdb_id: %s", imdbID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PlayersHandler) GetVibixPlayer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("GetVibixPlayer called: %s %s", r.Method, r.URL.Path)
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
log.Printf("Route vars: %+v", vars)
|
||||||
|
|
||||||
|
imdbID := vars["imdb_id"]
|
||||||
|
if imdbID == "" {
|
||||||
|
log.Printf("Error: imdb_id is empty")
|
||||||
|
http.Error(w, "imdb_id path param is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Processing imdb_id: %s", imdbID)
|
||||||
|
|
||||||
|
if h.config.VibixToken == "" {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("%s/api/v1/publisher/videos/imdb/%s", vibixHost, imdbID)
|
||||||
|
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", "Bearer "+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 imdb_id: %s", imdbID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRgShowsPlayer handles RgShows streaming requests
|
||||||
|
func (h *PlayersHandler) GetRgShowsPlayer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("GetRgShowsPlayer called: %s %s", r.Method, r.URL.Path)
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tmdbID := vars["tmdb_id"]
|
||||||
|
if tmdbID == "" {
|
||||||
|
log.Printf("Error: tmdb_id is empty")
|
||||||
|
http.Error(w, "tmdb_id path param is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Processing tmdb_id: %s", tmdbID)
|
||||||
|
|
||||||
|
pm := players.NewPlayersManager()
|
||||||
|
result, err := pm.GetMovieStreamByProvider("rgshows", tmdbID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting RgShows stream: %v", err)
|
||||||
|
http.Error(w, "Failed to get stream", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Success {
|
||||||
|
log.Printf("RgShows stream not found: %s", result.Error)
|
||||||
|
http.Error(w, "Stream not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create iframe with the stream URL
|
||||||
|
iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, result.StreamURL)
|
||||||
|
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>RgShows Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframe)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte(htmlDoc))
|
||||||
|
|
||||||
|
log.Printf("Successfully served RgShows player for tmdb_id: %s", tmdbID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRgShowsTVPlayer handles RgShows TV show streaming requests
|
||||||
|
func (h *PlayersHandler) GetRgShowsTVPlayer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("GetRgShowsTVPlayer called: %s %s", r.Method, r.URL.Path)
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tmdbID := vars["tmdb_id"]
|
||||||
|
seasonStr := vars["season"]
|
||||||
|
episodeStr := vars["episode"]
|
||||||
|
|
||||||
|
if tmdbID == "" || seasonStr == "" || episodeStr == "" {
|
||||||
|
log.Printf("Error: missing required parameters")
|
||||||
|
http.Error(w, "tmdb_id, season, and episode path params are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
season, err := strconv.Atoi(seasonStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error parsing season: %v", err)
|
||||||
|
http.Error(w, "Invalid season number", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
episode, err := strconv.Atoi(episodeStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error parsing episode: %v", err)
|
||||||
|
http.Error(w, "Invalid episode number", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Processing tmdb_id: %s, season: %d, episode: %d", tmdbID, season, episode)
|
||||||
|
|
||||||
|
pm := players.NewPlayersManager()
|
||||||
|
result, err := pm.GetTVStreamByProvider("rgshows", tmdbID, season, episode)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting RgShows TV stream: %v", err)
|
||||||
|
http.Error(w, "Failed to get stream", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Success {
|
||||||
|
log.Printf("RgShows TV stream not found: %s", result.Error)
|
||||||
|
http.Error(w, "Stream not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create iframe with the stream URL
|
||||||
|
iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, result.StreamURL)
|
||||||
|
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>RgShows TV Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframe)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte(htmlDoc))
|
||||||
|
|
||||||
|
log.Printf("Successfully served RgShows TV player for tmdb_id: %s, S%dE%d", tmdbID, season, episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIframeVideoPlayer handles IframeVideo streaming requests
|
||||||
|
func (h *PlayersHandler) GetIframeVideoPlayer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("GetIframeVideoPlayer called: %s %s", r.Method, r.URL.Path)
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
kinopoiskID := vars["kinopoisk_id"]
|
||||||
|
imdbID := vars["imdb_id"]
|
||||||
|
|
||||||
|
if kinopoiskID == "" && imdbID == "" {
|
||||||
|
log.Printf("Error: both kinopoisk_id and imdb_id are empty")
|
||||||
|
http.Error(w, "Either kinopoisk_id or imdb_id path param is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Processing kinopoisk_id: %s, imdb_id: %s", kinopoiskID, imdbID)
|
||||||
|
|
||||||
|
pm := players.NewPlayersManager()
|
||||||
|
result, err := pm.GetStreamWithKinopoisk(kinopoiskID, imdbID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting IframeVideo stream: %v", err)
|
||||||
|
http.Error(w, "Failed to get stream", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Success {
|
||||||
|
log.Printf("IframeVideo stream not found: %s", result.Error)
|
||||||
|
http.Error(w, "Stream not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create iframe with the stream URL
|
||||||
|
iframe := fmt.Sprintf(`<iframe src="%s" allowfullscreen loading="lazy" style="border:none;width:100%%;height:100%%;"></iframe>`, result.StreamURL)
|
||||||
|
htmlDoc := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset='utf-8'/><title>IframeVideo Player</title><style>html,body{margin:0;height:100%%;}</style></head><body>%s</body></html>`, iframe)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte(htmlDoc))
|
||||||
|
|
||||||
|
log.Printf("Successfully served IframeVideo player for kinopoisk_id: %s, imdb_id: %s", kinopoiskID, imdbID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStreamAPI returns stream information as JSON API
|
||||||
|
func (h *PlayersHandler) GetStreamAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("GetStreamAPI called: %s %s", r.Method, r.URL.Path)
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
provider := vars["provider"]
|
||||||
|
tmdbID := vars["tmdb_id"]
|
||||||
|
|
||||||
|
if provider == "" || tmdbID == "" {
|
||||||
|
log.Printf("Error: missing required parameters")
|
||||||
|
http.Error(w, "provider and tmdb_id path params are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for TV show parameters
|
||||||
|
seasonStr := r.URL.Query().Get("season")
|
||||||
|
episodeStr := r.URL.Query().Get("episode")
|
||||||
|
kinopoiskID := r.URL.Query().Get("kinopoisk_id")
|
||||||
|
imdbID := r.URL.Query().Get("imdb_id")
|
||||||
|
|
||||||
|
log.Printf("Processing provider: %s, tmdb_id: %s", provider, tmdbID)
|
||||||
|
|
||||||
|
pm := players.NewPlayersManager()
|
||||||
|
var result *players.StreamResult
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch provider {
|
||||||
|
case "iframevideo":
|
||||||
|
if kinopoiskID == "" && imdbID == "" {
|
||||||
|
http.Error(w, "kinopoisk_id or imdb_id query param is required for IframeVideo", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err = pm.GetStreamWithKinopoisk(kinopoiskID, imdbID)
|
||||||
|
case "rgshows":
|
||||||
|
if seasonStr != "" && episodeStr != "" {
|
||||||
|
season, err1 := strconv.Atoi(seasonStr)
|
||||||
|
episode, err2 := strconv.Atoi(episodeStr)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
http.Error(w, "Invalid season or episode number", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err = pm.GetTVStreamByProvider("rgshows", tmdbID, season, episode)
|
||||||
|
} else {
|
||||||
|
result, err = pm.GetMovieStreamByProvider("rgshows", tmdbID)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
http.Error(w, "Unsupported provider", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting stream from %s: %v", provider, err)
|
||||||
|
result = &players.StreamResult{
|
||||||
|
Success: false,
|
||||||
|
Provider: provider,
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
|
||||||
|
log.Printf("Successfully served stream API for provider: %s, tmdb_id: %s", provider, tmdbID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) 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})
|
||||||
|
}
|
||||||
42
pkg/handlers/search.go
Normal file
42
pkg/handlers/search.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
"neomovies-api/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SearchHandler struct {
|
||||||
|
tmdbService *services.TMDBService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSearchHandler(tmdbService *services.TMDBService) *SearchHandler {
|
||||||
|
return &SearchHandler{
|
||||||
|
tmdbService: tmdbService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SearchHandler) MultiSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query().Get("query")
|
||||||
|
if query == "" {
|
||||||
|
http.Error(w, "Query parameter is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := getIntQuery(r, "page", 1)
|
||||||
|
language := GetLanguage(r)
|
||||||
|
|
||||||
|
results, err := h.tmdbService.SearchMulti(query, page, language)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: results,
|
||||||
|
})
|
||||||
|
}
|
||||||
367
pkg/handlers/torrents.go
Normal file
367
pkg/handlers/torrents.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
"neomovies-api/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TorrentsHandler struct {
|
||||||
|
torrentService *services.TorrentService
|
||||||
|
tmdbService *services.TMDBService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTorrentsHandler(torrentService *services.TorrentService, tmdbService *services.TMDBService) *TorrentsHandler {
|
||||||
|
return &TorrentsHandler{
|
||||||
|
torrentService: torrentService,
|
||||||
|
tmdbService: tmdbService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTorrents - поиск торрентов по IMDB ID
|
||||||
|
func (h *TorrentsHandler) SearchTorrents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
imdbID := vars["imdbId"]
|
||||||
|
|
||||||
|
if imdbID == "" {
|
||||||
|
http.Error(w, "IMDB ID is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Параметры запроса
|
||||||
|
mediaType := r.URL.Query().Get("type")
|
||||||
|
if mediaType == "" {
|
||||||
|
mediaType = "movie"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем опции поиска
|
||||||
|
options := &models.TorrentSearchOptions{
|
||||||
|
ContentType: mediaType,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Качество
|
||||||
|
if quality := r.URL.Query().Get("quality"); quality != "" {
|
||||||
|
options.Quality = strings.Split(quality, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Минимальное и максимальное качество
|
||||||
|
options.MinQuality = r.URL.Query().Get("minQuality")
|
||||||
|
options.MaxQuality = r.URL.Query().Get("maxQuality")
|
||||||
|
|
||||||
|
// Исключаемые качества
|
||||||
|
if excludeQualities := r.URL.Query().Get("excludeQualities"); excludeQualities != "" {
|
||||||
|
options.ExcludeQualities = strings.Split(excludeQualities, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HDR
|
||||||
|
if hdr := r.URL.Query().Get("hdr"); hdr != "" {
|
||||||
|
if hdrBool, err := strconv.ParseBool(hdr); err == nil {
|
||||||
|
options.HDR = &hdrBool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEVC
|
||||||
|
if hevc := r.URL.Query().Get("hevc"); hevc != "" {
|
||||||
|
if hevcBool, err := strconv.ParseBool(hevc); err == nil {
|
||||||
|
options.HEVC = &hevcBool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортировка
|
||||||
|
options.SortBy = r.URL.Query().Get("sortBy")
|
||||||
|
if options.SortBy == "" {
|
||||||
|
options.SortBy = "seeders"
|
||||||
|
}
|
||||||
|
|
||||||
|
options.SortOrder = r.URL.Query().Get("sortOrder")
|
||||||
|
if options.SortOrder == "" {
|
||||||
|
options.SortOrder = "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группировка
|
||||||
|
if groupByQuality := r.URL.Query().Get("groupByQuality"); groupByQuality == "true" {
|
||||||
|
options.GroupByQuality = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if groupBySeason := r.URL.Query().Get("groupBySeason"); groupBySeason == "true" {
|
||||||
|
options.GroupBySeason = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сезон для сериалов
|
||||||
|
if season := r.URL.Query().Get("season"); season != "" {
|
||||||
|
if seasonInt, err := strconv.Atoi(season); err == nil {
|
||||||
|
options.Season = &seasonInt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск торрентов
|
||||||
|
results, err := h.torrentService.SearchTorrentsByIMDbID(h.tmdbService, imdbID, mediaType, options)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем ответ с группировкой если необходимо
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"imdbId": imdbID,
|
||||||
|
"type": mediaType,
|
||||||
|
"total": results.Total,
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.Season != nil {
|
||||||
|
response["season"] = *options.Season
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем группировку если запрошена
|
||||||
|
if options.GroupByQuality && options.GroupBySeason {
|
||||||
|
// Группируем сначала по сезонам, затем по качеству внутри каждого сезона
|
||||||
|
seasonGroups := h.torrentService.GroupBySeason(results.Results)
|
||||||
|
finalGroups := make(map[string]map[string][]models.TorrentResult)
|
||||||
|
|
||||||
|
for season, torrents := range seasonGroups {
|
||||||
|
qualityGroups := h.torrentService.GroupByQuality(torrents)
|
||||||
|
finalGroups[season] = qualityGroups
|
||||||
|
}
|
||||||
|
|
||||||
|
response["grouped"] = true
|
||||||
|
response["groups"] = finalGroups
|
||||||
|
} else if options.GroupByQuality {
|
||||||
|
groups := h.torrentService.GroupByQuality(results.Results)
|
||||||
|
response["grouped"] = true
|
||||||
|
response["groups"] = groups
|
||||||
|
} else if options.GroupBySeason {
|
||||||
|
groups := h.torrentService.GroupBySeason(results.Results)
|
||||||
|
response["grouped"] = true
|
||||||
|
response["groups"] = groups
|
||||||
|
} else {
|
||||||
|
response["grouped"] = false
|
||||||
|
response["results"] = results.Results
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results.Results) == 0 {
|
||||||
|
response["error"] = "No torrents found for this IMDB ID"
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchMovies - поиск фильмов по названию
|
||||||
|
func (h *TorrentsHandler) SearchMovies(w http.ResponseWriter, r *http.Request) {
|
||||||
|
title := r.URL.Query().Get("title")
|
||||||
|
originalTitle := r.URL.Query().Get("originalTitle")
|
||||||
|
year := r.URL.Query().Get("year")
|
||||||
|
|
||||||
|
if title == "" && originalTitle == "" {
|
||||||
|
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := h.torrentService.SearchMovies(title, originalTitle, year)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"title": title,
|
||||||
|
"originalTitle": originalTitle,
|
||||||
|
"year": year,
|
||||||
|
"type": "movie",
|
||||||
|
"total": results.Total,
|
||||||
|
"results": results.Results,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchSeries - поиск сериалов по названию с поддержкой сезонов
|
||||||
|
func (h *TorrentsHandler) SearchSeries(w http.ResponseWriter, r *http.Request) {
|
||||||
|
title := r.URL.Query().Get("title")
|
||||||
|
originalTitle := r.URL.Query().Get("originalTitle")
|
||||||
|
year := r.URL.Query().Get("year")
|
||||||
|
|
||||||
|
if title == "" && originalTitle == "" {
|
||||||
|
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var season *int
|
||||||
|
if seasonStr := r.URL.Query().Get("season"); seasonStr != "" {
|
||||||
|
if seasonInt, err := strconv.Atoi(seasonStr); err == nil {
|
||||||
|
season = &seasonInt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := h.torrentService.SearchSeries(title, originalTitle, year, season)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"title": title,
|
||||||
|
"originalTitle": originalTitle,
|
||||||
|
"year": year,
|
||||||
|
"type": "series",
|
||||||
|
"total": results.Total,
|
||||||
|
"results": results.Results,
|
||||||
|
}
|
||||||
|
|
||||||
|
if season != nil {
|
||||||
|
response["season"] = *season
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAnime - поиск аниме по названию
|
||||||
|
func (h *TorrentsHandler) SearchAnime(w http.ResponseWriter, r *http.Request) {
|
||||||
|
title := r.URL.Query().Get("title")
|
||||||
|
originalTitle := r.URL.Query().Get("originalTitle")
|
||||||
|
year := r.URL.Query().Get("year")
|
||||||
|
|
||||||
|
if title == "" && originalTitle == "" {
|
||||||
|
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := h.torrentService.SearchAnime(title, originalTitle, year)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"title": title,
|
||||||
|
"originalTitle": originalTitle,
|
||||||
|
"year": year,
|
||||||
|
"type": "anime",
|
||||||
|
"total": results.Total,
|
||||||
|
"results": results.Results,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailableSeasons - получение доступных сезонов для сериала
|
||||||
|
func (h *TorrentsHandler) GetAvailableSeasons(w http.ResponseWriter, r *http.Request) {
|
||||||
|
title := r.URL.Query().Get("title")
|
||||||
|
originalTitle := r.URL.Query().Get("originalTitle")
|
||||||
|
year := r.URL.Query().Get("year")
|
||||||
|
|
||||||
|
if title == "" && originalTitle == "" {
|
||||||
|
http.Error(w, "Title or original title is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seasons, err := h.torrentService.GetAvailableSeasons(title, originalTitle, year)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"title": title,
|
||||||
|
"originalTitle": originalTitle,
|
||||||
|
"year": year,
|
||||||
|
"seasons": seasons,
|
||||||
|
"total": len(seasons),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: response,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchByQuery - универсальный поиск торрентов
|
||||||
|
func (h *TorrentsHandler) SearchByQuery(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query().Get("query")
|
||||||
|
if query == "" {
|
||||||
|
http.Error(w, "Query is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := r.URL.Query().Get("type")
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "movie"
|
||||||
|
}
|
||||||
|
|
||||||
|
year := r.URL.Query().Get("year")
|
||||||
|
|
||||||
|
// Формируем параметры поиска
|
||||||
|
params := map[string]string{
|
||||||
|
"query": query,
|
||||||
|
}
|
||||||
|
|
||||||
|
if year != "" {
|
||||||
|
params["year"] = year
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем тип контента и категорию
|
||||||
|
switch contentType {
|
||||||
|
case "movie":
|
||||||
|
params["is_serial"] = "1"
|
||||||
|
params["category"] = "2000"
|
||||||
|
case "series", "tv":
|
||||||
|
params["is_serial"] = "2"
|
||||||
|
params["category"] = "5000"
|
||||||
|
case "anime":
|
||||||
|
params["is_serial"] = "5"
|
||||||
|
params["category"] = "5070"
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := h.torrentService.SearchTorrents(params)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем фильтрацию по типу контента
|
||||||
|
options := &models.TorrentSearchOptions{
|
||||||
|
ContentType: contentType,
|
||||||
|
}
|
||||||
|
results.Results = h.torrentService.FilterByContentType(results.Results, options.ContentType)
|
||||||
|
results.Total = len(results.Results)
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"query": query,
|
||||||
|
"type": contentType,
|
||||||
|
"year": year,
|
||||||
|
"total": results.Total,
|
||||||
|
"results": results.Results,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: response,
|
||||||
|
})
|
||||||
|
}
|
||||||
206
pkg/handlers/tv.go
Normal file
206
pkg/handlers/tv.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
"neomovies-api/pkg/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TVHandler struct {
|
||||||
|
tvService *services.TVService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTVHandler(tvService *services.TVService) *TVHandler {
|
||||||
|
return &TVHandler{
|
||||||
|
tvService: tvService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TVHandler) Search(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query().Get("query")
|
||||||
|
if query == "" {
|
||||||
|
http.Error(w, "Query parameter is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page := getIntQuery(r, "page", 1)
|
||||||
|
language := 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)
|
||||||
|
id, err := strconv.Atoi(vars["id"])
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid TV show ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
language := GetLanguage(r)
|
||||||
|
|
||||||
|
tvShow, err := h.tvService.GetByID(id, language)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(models.APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: tvShow,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TVHandler) Popular(w http.ResponseWriter, r *http.Request) {
|
||||||
|
page := getIntQuery(r, "page", 1)
|
||||||
|
language := 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"`
|
||||||
|
}
|
||||||
334
pkg/models/movie.go
Normal file
334
pkg/models/movie.go
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// MediaInfo represents media information structure used by handlers and services
|
||||||
|
type MediaInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
OriginalTitle string `json:"original_title,omitempty"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
PosterPath string `json:"poster_path"`
|
||||||
|
BackdropPath string `json:"backdrop_path"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
|
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||||
|
VoteAverage float64 `json:"vote_average"`
|
||||||
|
VoteCount int `json:"vote_count"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
Popularity float64 `json:"popularity"`
|
||||||
|
GenreIDs []int `json:"genre_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Movie struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
OriginalTitle string `json:"original_title"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
PosterPath string `json:"poster_path"`
|
||||||
|
BackdropPath string `json:"backdrop_path"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
GenreIDs []int `json:"genre_ids"`
|
||||||
|
Genres []Genre `json:"genres"`
|
||||||
|
VoteAverage float64 `json:"vote_average"`
|
||||||
|
VoteCount int `json:"vote_count"`
|
||||||
|
Popularity float64 `json:"popularity"`
|
||||||
|
Adult bool `json:"adult"`
|
||||||
|
Video bool `json:"video"`
|
||||||
|
OriginalLanguage string `json:"original_language"`
|
||||||
|
Runtime int `json:"runtime,omitempty"`
|
||||||
|
Budget int64 `json:"budget,omitempty"`
|
||||||
|
Revenue int64 `json:"revenue,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Tagline string `json:"tagline,omitempty"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
IMDbID string `json:"imdb_id,omitempty"`
|
||||||
|
BelongsToCollection *Collection `json:"belongs_to_collection,omitempty"`
|
||||||
|
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
|
||||||
|
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
|
||||||
|
SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TVShow struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
OriginalName string `json:"original_name"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
PosterPath string `json:"poster_path"`
|
||||||
|
BackdropPath string `json:"backdrop_path"`
|
||||||
|
FirstAirDate string `json:"first_air_date"`
|
||||||
|
LastAirDate string `json:"last_air_date"`
|
||||||
|
GenreIDs []int `json:"genre_ids"`
|
||||||
|
Genres []Genre `json:"genres"`
|
||||||
|
VoteAverage float64 `json:"vote_average"`
|
||||||
|
VoteCount int `json:"vote_count"`
|
||||||
|
Popularity float64 `json:"popularity"`
|
||||||
|
OriginalLanguage string `json:"original_language"`
|
||||||
|
OriginCountry []string `json:"origin_country"`
|
||||||
|
NumberOfEpisodes int `json:"number_of_episodes,omitempty"`
|
||||||
|
NumberOfSeasons int `json:"number_of_seasons,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Homepage string `json:"homepage,omitempty"`
|
||||||
|
InProduction bool `json:"in_production,omitempty"`
|
||||||
|
Languages []string `json:"languages,omitempty"`
|
||||||
|
Networks []Network `json:"networks,omitempty"`
|
||||||
|
ProductionCompanies []ProductionCompany `json:"production_companies,omitempty"`
|
||||||
|
ProductionCountries []ProductionCountry `json:"production_countries,omitempty"`
|
||||||
|
SpokenLanguages []SpokenLanguage `json:"spoken_languages,omitempty"`
|
||||||
|
CreatedBy []Creator `json:"created_by,omitempty"`
|
||||||
|
EpisodeRunTime []int `json:"episode_run_time,omitempty"`
|
||||||
|
Seasons []Season `json:"seasons,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiSearchResult для мультипоиска
|
||||||
|
type MultiSearchResult struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
MediaType string `json:"media_type"` // "movie" или "tv"
|
||||||
|
Title string `json:"title,omitempty"` // для фильмов
|
||||||
|
Name string `json:"name,omitempty"` // для сериалов
|
||||||
|
OriginalTitle string `json:"original_title,omitempty"`
|
||||||
|
OriginalName string `json:"original_name,omitempty"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
PosterPath string `json:"poster_path"`
|
||||||
|
BackdropPath string `json:"backdrop_path"`
|
||||||
|
ReleaseDate string `json:"release_date,omitempty"` // для фильмов
|
||||||
|
FirstAirDate string `json:"first_air_date,omitempty"` // для сериалов
|
||||||
|
GenreIDs []int `json:"genre_ids"`
|
||||||
|
VoteAverage float64 `json:"vote_average"`
|
||||||
|
VoteCount int `json:"vote_count"`
|
||||||
|
Popularity float64 `json:"popularity"`
|
||||||
|
Adult bool `json:"adult"`
|
||||||
|
OriginalLanguage string `json:"original_language"`
|
||||||
|
OriginCountry []string `json:"origin_country,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MultiSearchResponse struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
Results []MultiSearchResult `json:"results"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
TotalResults int `json:"total_results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Genre struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GenresResponse struct {
|
||||||
|
Genres []Genre `json:"genres"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalIDs struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
IMDbID string `json:"imdb_id"`
|
||||||
|
TVDBID int `json:"tvdb_id,omitempty"`
|
||||||
|
WikidataID string `json:"wikidata_id"`
|
||||||
|
FacebookID string `json:"facebook_id"`
|
||||||
|
InstagramID string `json:"instagram_id"`
|
||||||
|
TwitterID string `json:"twitter_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Collection struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PosterPath string `json:"poster_path"`
|
||||||
|
BackdropPath string `json:"backdrop_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductionCompany struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
LogoPath string `json:"logo_path"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
OriginCountry string `json:"origin_country"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductionCountry struct {
|
||||||
|
ISO31661 string `json:"iso_3166_1"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpokenLanguage struct {
|
||||||
|
EnglishName string `json:"english_name"`
|
||||||
|
ISO6391 string `json:"iso_639_1"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Network struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
LogoPath string `json:"logo_path"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
OriginCountry string `json:"origin_country"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Creator struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
CreditID string `json:"credit_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Gender int `json:"gender"`
|
||||||
|
ProfilePath string `json:"profile_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Season struct {
|
||||||
|
AirDate string `json:"air_date"`
|
||||||
|
EpisodeCount int `json:"episode_count"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
PosterPath string `json:"poster_path"`
|
||||||
|
SeasonNumber int `json:"season_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeasonDetails struct {
|
||||||
|
AirDate string `json:"air_date"`
|
||||||
|
Episodes []Episode `json:"episodes"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
PosterPath string `json:"poster_path"`
|
||||||
|
SeasonNumber int `json:"season_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Episode struct {
|
||||||
|
AirDate string `json:"air_date"`
|
||||||
|
EpisodeNumber int `json:"episode_number"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
ProductionCode string `json:"production_code"`
|
||||||
|
Runtime int `json:"runtime"`
|
||||||
|
SeasonNumber int `json:"season_number"`
|
||||||
|
ShowID int `json:"show_id"`
|
||||||
|
StillPath string `json:"still_path"`
|
||||||
|
VoteAverage float64 `json:"vote_average"`
|
||||||
|
VoteCount int `json:"vote_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TMDBResponse struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
Results []Movie `json:"results"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
TotalResults int `json:"total_results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TMDBTVResponse struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
Results []TVShow `json:"results"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
TotalResults int `json:"total_results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchParams struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
PrimaryReleaseYear int `json:"primary_release_year"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модели для торрентов
|
||||||
|
type TorrentResult struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Tracker string `json:"tracker"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Seeders int `json:"seeders"`
|
||||||
|
Peers int `json:"peers"`
|
||||||
|
Leechers int `json:"leechers"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
Voice []string `json:"voice,omitempty"`
|
||||||
|
Types []string `json:"types,omitempty"`
|
||||||
|
Seasons []int `json:"seasons,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
MagnetLink string `json:"magnet"`
|
||||||
|
TorrentLink string `json:"torrent_link,omitempty"`
|
||||||
|
Details string `json:"details,omitempty"`
|
||||||
|
PublishDate string `json:"publish_date"`
|
||||||
|
AddedDate string `json:"added_date,omitempty"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentSearchResponse struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Results []TorrentResult `json:"results"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedAPI специфичные структуры
|
||||||
|
type RedAPIResponse struct {
|
||||||
|
Results []RedAPITorrent `json:"Results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedAPITorrent struct {
|
||||||
|
Title string `json:"Title"`
|
||||||
|
Tracker string `json:"Tracker"`
|
||||||
|
Size interface{} `json:"Size"` // Может быть string или number
|
||||||
|
Seeders int `json:"Seeders"`
|
||||||
|
Peers int `json:"Peers"`
|
||||||
|
MagnetUri string `json:"MagnetUri"`
|
||||||
|
PublishDate string `json:"PublishDate"`
|
||||||
|
CategoryDesc string `json:"CategoryDesc"`
|
||||||
|
Details string `json:"Details"`
|
||||||
|
Info *RedAPITorrentInfo `json:"Info,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedAPITorrentInfo struct {
|
||||||
|
Quality interface{} `json:"quality,omitempty"` // Может быть string или number
|
||||||
|
Voices []string `json:"voices,omitempty"`
|
||||||
|
Types []string `json:"types,omitempty"`
|
||||||
|
Seasons []int `json:"seasons,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alloha API структуры для получения информации о фильмах
|
||||||
|
type AllohaResponse struct {
|
||||||
|
Data *AllohaData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllohaData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
OriginalName string `json:"original_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Опции поиска торрентов
|
||||||
|
type TorrentSearchOptions struct {
|
||||||
|
Season *int
|
||||||
|
Quality []string
|
||||||
|
MinQuality string
|
||||||
|
MaxQuality string
|
||||||
|
ExcludeQualities []string
|
||||||
|
HDR *bool
|
||||||
|
HEVC *bool
|
||||||
|
SortBy string
|
||||||
|
SortOrder string
|
||||||
|
GroupByQuality bool
|
||||||
|
GroupBySeason bool
|
||||||
|
ContentType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модели для плееров
|
||||||
|
type PlayerResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Iframe string `json:"iframe,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Модели для реакций
|
||||||
|
type Reaction struct {
|
||||||
|
ID string `json:"id" bson:"_id,omitempty"`
|
||||||
|
UserID string `json:"userId" bson:"userId"`
|
||||||
|
MediaID string `json:"mediaId" bson:"mediaId"`
|
||||||
|
Type string `json:"type" bson:"type"`
|
||||||
|
Created string `json:"created" bson:"created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReactionCounts struct {
|
||||||
|
Fire int `json:"fire"`
|
||||||
|
Nice int `json:"nice"`
|
||||||
|
Think int `json:"think"`
|
||||||
|
Bore int `json:"bore"`
|
||||||
|
Shit int `json:"shit"`
|
||||||
|
}
|
||||||
69
pkg/models/user.go
Normal file
69
pkg/models/user.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID primitive.ObjectID `json:"id" bson:"_id,omitempty"`
|
||||||
|
Email string `json:"email" bson:"email" validate:"required,email"`
|
||||||
|
Password string `json:"-" bson:"password" validate:"required,min=6"`
|
||||||
|
Name string `json:"name" bson:"name" validate:"required"`
|
||||||
|
Avatar string `json:"avatar" bson:"avatar"`
|
||||||
|
Favorites []string `json:"favorites" bson:"favorites"`
|
||||||
|
Verified bool `json:"verified" bson:"verified"`
|
||||||
|
VerificationCode string `json:"-" bson:"verificationCode,omitempty"`
|
||||||
|
VerificationExpires time.Time `json:"-" bson:"verificationExpires,omitempty"`
|
||||||
|
IsAdmin bool `json:"isAdmin" bson:"isAdmin"`
|
||||||
|
AdminVerified bool `json:"adminVerified" bson:"adminVerified"`
|
||||||
|
CreatedAt time.Time `json:"created_at" bson:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" bson:"updatedAt"`
|
||||||
|
Provider string `json:"provider,omitempty" bson:"provider,omitempty"`
|
||||||
|
GoogleID string `json:"googleId,omitempty" bson:"googleId,omitempty"`
|
||||||
|
RefreshTokens []RefreshToken `json:"-" bson:"refreshTokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required,min=6"`
|
||||||
|
Name string `json:"name" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
User User `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyEmailRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Code string `json:"code" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResendCodeRequest struct {
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshToken struct {
|
||||||
|
Token string `json:"token" bson:"token"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt" bson:"expiresAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt" bson:"createdAt"`
|
||||||
|
UserAgent string `json:"userAgent,omitempty" bson:"userAgent,omitempty"`
|
||||||
|
IPAddress string `json:"ipAddress,omitempty" bson:"ipAddress,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefreshTokenRequest struct {
|
||||||
|
RefreshToken string `json:"refreshToken" validate:"required"`
|
||||||
|
}
|
||||||
91
pkg/monitor/monitor.go
Normal file
91
pkg/monitor/monitor.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package monitor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestMonitor создает middleware для мониторинга запросов в стиле htop
|
||||||
|
func RequestMonitor() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Создаем wrapper для ResponseWriter чтобы получить статус код
|
||||||
|
ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
|
||||||
|
// Выполняем запрос
|
||||||
|
next.ServeHTTP(ww, r)
|
||||||
|
|
||||||
|
// Вычисляем время выполнения
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
// Форматируем URL (обрезаем если слишком длинный)
|
||||||
|
url := r.URL.Path
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
url += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
if len(url) > 60 {
|
||||||
|
url = url[:57] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем цвет статуса
|
||||||
|
statusColor := getStatusColor(ww.statusCode)
|
||||||
|
methodColor := getMethodColor(r.Method)
|
||||||
|
|
||||||
|
// Выводим информацию о запросе
|
||||||
|
fmt.Printf("\033[2K\r%s%-6s\033[0m %s%-3d\033[0m │ %-60s │ %6.2fms\n",
|
||||||
|
methodColor, r.Method,
|
||||||
|
statusColor, ww.statusCode,
|
||||||
|
url,
|
||||||
|
float64(duration.Nanoseconds())/1000000,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type responseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *responseWriter) WriteHeader(code int) {
|
||||||
|
rw.statusCode = code
|
||||||
|
rw.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStatusColor возвращает ANSI цвет для статус кода
|
||||||
|
func getStatusColor(status int) string {
|
||||||
|
switch {
|
||||||
|
case status >= 200 && status < 300:
|
||||||
|
return "\033[32m" // Зеленый
|
||||||
|
case status >= 300 && status < 400:
|
||||||
|
return "\033[33m" // Желтый
|
||||||
|
case status >= 400 && status < 500:
|
||||||
|
return "\033[31m" // Красный
|
||||||
|
case status >= 500:
|
||||||
|
return "\033[35m" // Фиолетовый
|
||||||
|
default:
|
||||||
|
return "\033[37m" // Белый
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMethodColor возвращает ANSI цвет для HTTP метода
|
||||||
|
func getMethodColor(method string) string {
|
||||||
|
switch strings.ToUpper(method) {
|
||||||
|
case "GET":
|
||||||
|
return "\033[34m" // Синий
|
||||||
|
case "POST":
|
||||||
|
return "\033[32m" // Зеленый
|
||||||
|
case "PUT":
|
||||||
|
return "\033[33m" // Желтый
|
||||||
|
case "DELETE":
|
||||||
|
return "\033[31m" // Красный
|
||||||
|
case "PATCH":
|
||||||
|
return "\033[36m" // Циан
|
||||||
|
default:
|
||||||
|
return "\033[37m" // Белый
|
||||||
|
}
|
||||||
|
}
|
||||||
208
pkg/players/iframevideo.go
Normal file
208
pkg/players/iframevideo.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package players
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IframeVideoSearchResponse represents the search response from IframeVideo API
|
||||||
|
type IframeVideoSearchResponse struct {
|
||||||
|
Results []struct {
|
||||||
|
CID int `json:"cid"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IframeVideoResponse represents the video response from IframeVideo API
|
||||||
|
type IframeVideoResponse struct {
|
||||||
|
Source string `json:"src"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IframeVideoPlayer implements the IframeVideo streaming service
|
||||||
|
type IframeVideoPlayer struct {
|
||||||
|
APIHost string
|
||||||
|
CDNHost string
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIframeVideoPlayer creates a new IframeVideo player instance
|
||||||
|
func NewIframeVideoPlayer() *IframeVideoPlayer {
|
||||||
|
return &IframeVideoPlayer{
|
||||||
|
APIHost: "https://iframe.video",
|
||||||
|
CDNHost: "https://videoframe.space",
|
||||||
|
Client: &http.Client{
|
||||||
|
Timeout: 8 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStream gets streaming URL by Kinopoisk ID and IMDB ID
|
||||||
|
func (i *IframeVideoPlayer) GetStream(kinopoiskID, imdbID string) (*StreamResult, error) {
|
||||||
|
// First, search for content
|
||||||
|
searchResult, err := i.searchContent(kinopoiskID, imdbID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get iframe content to extract token
|
||||||
|
token, err := i.extractToken(searchResult.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token extraction failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get video URL
|
||||||
|
return i.getVideoURL(searchResult.CID, token, searchResult.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchContent searches for content by Kinopoisk and IMDB IDs
|
||||||
|
func (i *IframeVideoPlayer) searchContent(kinopoiskID, imdbID string) (*struct {
|
||||||
|
CID int
|
||||||
|
Path string
|
||||||
|
Type string
|
||||||
|
}, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/v2/search?imdb=%s&kp=%s", i.APIHost, imdbID, kinopoiskID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := i.Client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch search results: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("API returned status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp IframeVideoSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Results) == 0 {
|
||||||
|
return nil, fmt.Errorf("content not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := searchResp.Results[0]
|
||||||
|
return &struct {
|
||||||
|
CID int
|
||||||
|
Path string
|
||||||
|
Type string
|
||||||
|
}{
|
||||||
|
CID: result.CID,
|
||||||
|
Path: result.Path,
|
||||||
|
Type: result.Type,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractToken extracts token from iframe HTML content
|
||||||
|
func (i *IframeVideoPlayer) extractToken(path string) (string, error) {
|
||||||
|
req, err := http.NewRequest("GET", path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers similar to C# implementation
|
||||||
|
req.Header.Set("DNT", "1")
|
||||||
|
req.Header.Set("Referer", i.CDNHost+"/")
|
||||||
|
req.Header.Set("Sec-Fetch-Dest", "iframe")
|
||||||
|
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||||
|
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||||
|
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||||
|
req.Header.Set("sec-ch-ua", `"Google Chrome";v="113", "Chromium";v="113", "Not-A.Brand";v="24"`)
|
||||||
|
req.Header.Set("sec-ch-ua-mobile", "?0")
|
||||||
|
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := i.Client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch iframe content: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("iframe returned status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read iframe content: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract token using regex as in C# implementation
|
||||||
|
re := regexp.MustCompile(`\/[^\/]+\/([^\/]+)\/iframe`)
|
||||||
|
matches := re.FindStringSubmatch(string(content))
|
||||||
|
if len(matches) < 2 {
|
||||||
|
return "", fmt.Errorf("token not found in iframe content")
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVideoURL gets video URL using extracted token
|
||||||
|
func (i *IframeVideoPlayer) getVideoURL(cid int, token, mediaType string) (*StreamResult, error) {
|
||||||
|
// Create multipart form data
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&buf)
|
||||||
|
|
||||||
|
writer.WriteField("token", token)
|
||||||
|
writer.WriteField("type", mediaType)
|
||||||
|
writer.WriteField("season", "")
|
||||||
|
writer.WriteField("episode", "")
|
||||||
|
writer.WriteField("mobile", "false")
|
||||||
|
writer.WriteField("id", strconv.Itoa(cid))
|
||||||
|
writer.WriteField("qt", "480")
|
||||||
|
|
||||||
|
contentType := writer.FormDataContentType()
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", i.CDNHost+"/loadvideo", &buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
req.Header.Set("Origin", i.CDNHost)
|
||||||
|
req.Header.Set("Referer", i.CDNHost+"/")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := i.Client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch video URL: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("video API returned status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var videoResp IframeVideoResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&videoResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode video response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if videoResp.Source == "" {
|
||||||
|
return nil, fmt.Errorf("video URL not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StreamResult{
|
||||||
|
Success: true,
|
||||||
|
StreamURL: videoResp.Source,
|
||||||
|
Provider: "IframeVideo",
|
||||||
|
Type: "direct",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
81
pkg/players/rgshows.go
Normal file
81
pkg/players/rgshows.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package players
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RgShowsResponse represents the response from RgShows API
|
||||||
|
type RgShowsResponse struct {
|
||||||
|
Stream *struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RgShowsPlayer implements the RgShows streaming service
|
||||||
|
type RgShowsPlayer struct {
|
||||||
|
BaseURL string
|
||||||
|
Client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRgShowsPlayer creates a new RgShows player instance
|
||||||
|
func NewRgShowsPlayer() *RgShowsPlayer {
|
||||||
|
return &RgShowsPlayer{
|
||||||
|
BaseURL: "https://rgshows.com",
|
||||||
|
Client: &http.Client{
|
||||||
|
Timeout: 40 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMovieStream gets streaming URL for a movie by TMDB ID
|
||||||
|
func (r *RgShowsPlayer) GetMovieStream(tmdbID string) (*StreamResult, error) {
|
||||||
|
url := fmt.Sprintf("%s/main/movie/%s", r.BaseURL, tmdbID)
|
||||||
|
return r.fetchStream(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTVStream gets streaming URL for a TV show episode by TMDB ID, season and episode
|
||||||
|
func (r *RgShowsPlayer) GetTVStream(tmdbID string, season, episode int) (*StreamResult, error) {
|
||||||
|
url := fmt.Sprintf("%s/main/tv/%s/%d/%d", r.BaseURL, tmdbID, season, episode)
|
||||||
|
return r.fetchStream(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchStream makes HTTP request to RgShows API and extracts stream URL
|
||||||
|
func (r *RgShowsPlayer) fetchStream(url string) (*StreamResult, error) {
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers similar to the C# implementation
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36")
|
||||||
|
|
||||||
|
resp, err := r.Client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch stream: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("API returned status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rgResp RgShowsResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&rgResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rgResp.Stream == nil || rgResp.Stream.URL == "" {
|
||||||
|
return nil, fmt.Errorf("stream not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StreamResult{
|
||||||
|
Success: true,
|
||||||
|
StreamURL: rgResp.Stream.URL,
|
||||||
|
Provider: "RgShows",
|
||||||
|
Type: "direct",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
99
pkg/players/types.go
Normal file
99
pkg/players/types.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package players
|
||||||
|
|
||||||
|
// StreamResult represents the result of a streaming request
|
||||||
|
type StreamResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
StreamURL string `json:"stream_url,omitempty"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Type string `json:"type"` // "direct", "iframe", "hls", etc.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player interface defines methods for streaming providers
|
||||||
|
type Player interface {
|
||||||
|
GetMovieStream(tmdbID string) (*StreamResult, error)
|
||||||
|
GetTVStream(tmdbID string, season, episode int) (*StreamResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayersManager manages all available streaming players
|
||||||
|
type PlayersManager struct {
|
||||||
|
rgshows *RgShowsPlayer
|
||||||
|
iframevideo *IframeVideoPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPlayersManager creates a new players manager
|
||||||
|
func NewPlayersManager() *PlayersManager {
|
||||||
|
return &PlayersManager{
|
||||||
|
rgshows: NewRgShowsPlayer(),
|
||||||
|
iframevideo: NewIframeVideoPlayer(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMovieStreams tries to get movie streams from all available providers
|
||||||
|
func (pm *PlayersManager) GetMovieStreams(tmdbID string) []*StreamResult {
|
||||||
|
var results []*StreamResult
|
||||||
|
|
||||||
|
// Try RgShows
|
||||||
|
if stream, err := pm.rgshows.GetMovieStream(tmdbID); err == nil {
|
||||||
|
results = append(results, stream)
|
||||||
|
} else {
|
||||||
|
results = append(results, &StreamResult{
|
||||||
|
Success: false,
|
||||||
|
Provider: "RgShows",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTVStreams tries to get TV show streams from all available providers
|
||||||
|
func (pm *PlayersManager) GetTVStreams(tmdbID string, season, episode int) []*StreamResult {
|
||||||
|
var results []*StreamResult
|
||||||
|
|
||||||
|
// Try RgShows
|
||||||
|
if stream, err := pm.rgshows.GetTVStream(tmdbID, season, episode); err == nil {
|
||||||
|
results = append(results, stream)
|
||||||
|
} else {
|
||||||
|
results = append(results, &StreamResult{
|
||||||
|
Success: false,
|
||||||
|
Provider: "RgShows",
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMovieStreamByProvider gets movie stream from specific provider
|
||||||
|
func (pm *PlayersManager) GetMovieStreamByProvider(provider, tmdbID string) (*StreamResult, error) {
|
||||||
|
switch provider {
|
||||||
|
case "rgshows":
|
||||||
|
return pm.rgshows.GetMovieStream(tmdbID)
|
||||||
|
default:
|
||||||
|
return &StreamResult{
|
||||||
|
Success: false,
|
||||||
|
Provider: provider,
|
||||||
|
Error: "provider not found",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTVStreamByProvider gets TV stream from specific provider
|
||||||
|
func (pm *PlayersManager) GetTVStreamByProvider(provider, tmdbID string, season, episode int) (*StreamResult, error) {
|
||||||
|
switch provider {
|
||||||
|
case "rgshows":
|
||||||
|
return pm.rgshows.GetTVStream(tmdbID, season, episode)
|
||||||
|
default:
|
||||||
|
return &StreamResult{
|
||||||
|
Success: false,
|
||||||
|
Provider: provider,
|
||||||
|
Error: "provider not found",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStreamWithKinopoisk gets stream using Kinopoisk ID and IMDB ID (for IframeVideo)
|
||||||
|
func (pm *PlayersManager) GetStreamWithKinopoisk(kinopoiskID, imdbID string) (*StreamResult, error) {
|
||||||
|
return pm.iframevideo.GetStream(kinopoiskID, imdbID)
|
||||||
|
}
|
||||||
654
pkg/services/auth.go
Normal file
654
pkg/services/auth.go
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthService contains the database connection, JWT secret, and email service.
|
||||||
|
type AuthService struct {
|
||||||
|
db *mongo.Database
|
||||||
|
jwtSecret string
|
||||||
|
emailService *EmailService
|
||||||
|
baseURL string
|
||||||
|
googleClientID string
|
||||||
|
googleClientSecret string
|
||||||
|
googleRedirectURL string
|
||||||
|
frontendURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaction represents a reaction entry in the database.
|
||||||
|
type Reaction struct {
|
||||||
|
MediaID string `bson:"mediaId"`
|
||||||
|
Type string `bson:"type"`
|
||||||
|
UserID primitive.ObjectID `bson:"userId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthService creates and initializes a new AuthService.
|
||||||
|
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService, baseURL string, googleClientID string, googleClientSecret string, googleRedirectURL string, frontendURL string) *AuthService {
|
||||||
|
service := &AuthService{
|
||||||
|
db: db,
|
||||||
|
jwtSecret: jwtSecret,
|
||||||
|
emailService: emailService,
|
||||||
|
baseURL: baseURL,
|
||||||
|
googleClientID: googleClientID,
|
||||||
|
googleClientSecret: googleClientSecret,
|
||||||
|
googleRedirectURL: googleRedirectURL,
|
||||||
|
frontendURL: frontendURL,
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) googleOAuthConfig() *oauth2.Config {
|
||||||
|
redirectURL := s.googleRedirectURL
|
||||||
|
if redirectURL == "" && s.baseURL != "" {
|
||||||
|
redirectURL = fmt.Sprintf("%s/api/v1/auth/google/callback", s.baseURL)
|
||||||
|
}
|
||||||
|
return &oauth2.Config{
|
||||||
|
ClientID: s.googleClientID,
|
||||||
|
ClientSecret: s.googleClientSecret,
|
||||||
|
RedirectURL: redirectURL,
|
||||||
|
Scopes: []string{"openid", "email", "profile"},
|
||||||
|
Endpoint: google.Endpoint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) GetGoogleLoginURL(state string) (string, error) {
|
||||||
|
cfg := s.googleOAuthConfig()
|
||||||
|
if cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" {
|
||||||
|
return "", errors.New("google oauth not configured")
|
||||||
|
}
|
||||||
|
return cfg.AuthCodeURL(state, oauth2.AccessTypeOffline), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type googleUserInfo struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFrontendRedirect builds frontend URL for redirect after OAuth; returns false if not configured
|
||||||
|
func (s *AuthService) BuildFrontendRedirect(token string, authErr string) (string, bool) {
|
||||||
|
if s.frontendURL == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if authErr != "" {
|
||||||
|
u, _ := url.Parse(s.frontendURL + "/login")
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("oauth", "google")
|
||||||
|
q.Set("error", authErr)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), true
|
||||||
|
}
|
||||||
|
u, _ := url.Parse(s.frontendURL + "/auth/callback")
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("provider", "google")
|
||||||
|
q.Set("token", token)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) HandleGoogleCallback(ctx context.Context, code string) (*models.AuthResponse, error) {
|
||||||
|
cfg := s.googleOAuthConfig()
|
||||||
|
tok, err := cfg.Exchange(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := cfg.Client(ctx, tok)
|
||||||
|
resp, err := client.Get("https://www.googleapis.com/oauth2/v3/userinfo")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch userinfo: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("userinfo request failed: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var gUser googleUserInfo
|
||||||
|
if err := json.Unmarshal(body, &gUser); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse userinfo: %w", err)
|
||||||
|
}
|
||||||
|
if gUser.Email == "" {
|
||||||
|
return nil, errors.New("email not provided by Google")
|
||||||
|
}
|
||||||
|
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
|
||||||
|
// Try by googleId first
|
||||||
|
var user models.User
|
||||||
|
err = collection.FindOne(ctx, bson.M{"googleId": gUser.Sub}).Decode(&user)
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
// Try by email
|
||||||
|
err = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user)
|
||||||
|
}
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
// Create new user
|
||||||
|
user = models.User{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
Email: gUser.Email,
|
||||||
|
Password: "",
|
||||||
|
Name: gUser.Name,
|
||||||
|
Avatar: gUser.Picture,
|
||||||
|
Favorites: []string{},
|
||||||
|
Verified: true,
|
||||||
|
IsAdmin: false,
|
||||||
|
AdminVerified: false,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
Provider: "google",
|
||||||
|
GoogleID: gUser.Sub,
|
||||||
|
}
|
||||||
|
if _, err := collection.InsertOne(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
// Existing user: ensure fields
|
||||||
|
update := bson.M{
|
||||||
|
"verified": true,
|
||||||
|
"provider": "google",
|
||||||
|
"googleId": gUser.Sub,
|
||||||
|
"updatedAt": time.Now(),
|
||||||
|
}
|
||||||
|
if user.Name == "" && gUser.Name != "" {
|
||||||
|
update["name"] = gUser.Name
|
||||||
|
}
|
||||||
|
if user.Avatar == "" && gUser.Picture != "" {
|
||||||
|
update["avatar"] = gUser.Picture
|
||||||
|
}
|
||||||
|
_, _ = collection.UpdateOne(ctx, bson.M{"_id": user.ID}, bson.M{"$set": update})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
if user.ID.IsZero() {
|
||||||
|
// If we created user above, we already have user.ID set; else fetch updated
|
||||||
|
_ = collection.FindOne(ctx, bson.M{"email": gUser.Email}).Decode(&user)
|
||||||
|
}
|
||||||
|
tokenPair, err := s.generateTokenPair(user.ID.Hex(), "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.AuthResponse{
|
||||||
|
Token: tokenPair.AccessToken,
|
||||||
|
RefreshToken: tokenPair.RefreshToken,
|
||||||
|
User: user,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateVerificationCode creates a 6-digit verification code.
|
||||||
|
func (s *AuthService) generateVerificationCode() string {
|
||||||
|
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers a new user.
|
||||||
|
func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) {
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
|
||||||
|
var existingUser models.User
|
||||||
|
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser)
|
||||||
|
if err == nil {
|
||||||
|
return nil, errors.New("email already registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
code := s.generateVerificationCode()
|
||||||
|
codeExpires := time.Now().Add(10 * time.Minute)
|
||||||
|
|
||||||
|
user := models.User{
|
||||||
|
ID: primitive.NewObjectID(),
|
||||||
|
Email: req.Email,
|
||||||
|
Password: string(hashedPassword),
|
||||||
|
Name: req.Name,
|
||||||
|
Favorites: []string{},
|
||||||
|
Verified: false,
|
||||||
|
VerificationCode: code,
|
||||||
|
VerificationExpires: codeExpires,
|
||||||
|
IsAdmin: false,
|
||||||
|
AdminVerified: false,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = collection.InsertOne(context.Background(), user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.emailService != nil {
|
||||||
|
go s.emailService.SendVerificationEmail(user.Email, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Registered. Check email for verification code.",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a user.
|
||||||
|
func (s *AuthService) LoginWithTokens(req models.LoginRequest, userAgent, ipAddress string) (*models.AuthResponse, error) {
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.Verified {
|
||||||
|
return nil, errors.New("Account not activated. Please verify your email.")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("Invalid password")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenPair, err := s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.AuthResponse{
|
||||||
|
Token: tokenPair.AccessToken,
|
||||||
|
RefreshToken: tokenPair.RefreshToken,
|
||||||
|
User: user,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a user (legacy method for backward compatibility).
|
||||||
|
func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) {
|
||||||
|
return s.LoginWithTokens(req, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID retrieves a user by their ID.
|
||||||
|
func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
err = collection.FindOne(context.Background(), bson.M{"_id": objectID}).Decode(&user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates a user's information.
|
||||||
|
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updates["updated_at"] = time.Now()
|
||||||
|
|
||||||
|
_, err = collection.UpdateOne(
|
||||||
|
context.Background(),
|
||||||
|
bson.M{"_id": objectID},
|
||||||
|
bson.M{"$set": updates},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetUserByID(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateJWT generates a new JWT for a given user ID.
|
||||||
|
func (s *AuthService) generateJWT(userID string) (string, error) {
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"user_id": userID,
|
||||||
|
"exp": time.Now().Add(time.Hour * 1).Unix(), // Сократил время жизни до 1 часа
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"jti": uuid.New().String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(s.jwtSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRefreshToken generates a new refresh token
|
||||||
|
func (s *AuthService) generateRefreshToken() string {
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTokenPair generates both access and refresh tokens
|
||||||
|
func (s *AuthService) generateTokenPair(userID, userAgent, ipAddress string) (*models.TokenPair, error) {
|
||||||
|
accessToken, err := s.generateJWT(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken := s.generateRefreshToken()
|
||||||
|
|
||||||
|
// Сохраняем refresh token в базе данных
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshTokenDoc := models.RefreshToken{
|
||||||
|
Token: refreshToken,
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour * 24 * 30), // 30 дней
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UserAgent: userAgent,
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем старые истекшие токены и добавляем новый
|
||||||
|
_, err = collection.UpdateOne(
|
||||||
|
context.Background(),
|
||||||
|
bson.M{"_id": objectID},
|
||||||
|
bson.M{
|
||||||
|
"$pull": bson.M{
|
||||||
|
"refreshTokens": bson.M{
|
||||||
|
"expiresAt": bson.M{"$lt": time.Now()},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = collection.UpdateOne(
|
||||||
|
context.Background(),
|
||||||
|
bson.M{"_id": objectID},
|
||||||
|
bson.M{
|
||||||
|
"$push": bson.M{
|
||||||
|
"refreshTokens": refreshTokenDoc,
|
||||||
|
},
|
||||||
|
"$set": bson.M{
|
||||||
|
"updatedAt": time.Now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.TokenPair{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAccessToken refreshes an access token using a refresh token
|
||||||
|
func (s *AuthService) RefreshAccessToken(refreshToken, userAgent, ipAddress string) (*models.TokenPair, error) {
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
|
||||||
|
// Найти пользователя с данным refresh токеном
|
||||||
|
var user models.User
|
||||||
|
err := collection.FindOne(
|
||||||
|
context.Background(),
|
||||||
|
bson.M{
|
||||||
|
"refreshTokens": bson.M{
|
||||||
|
"$elemMatch": bson.M{
|
||||||
|
"token": refreshToken,
|
||||||
|
"expiresAt": bson.M{"$gt": time.Now()},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).Decode(&user)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid or expired refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить использованный refresh token
|
||||||
|
_, err = collection.UpdateOne(
|
||||||
|
context.Background(),
|
||||||
|
bson.M{"_id": user.ID},
|
||||||
|
bson.M{
|
||||||
|
"$pull": bson.M{
|
||||||
|
"refreshTokens": bson.M{
|
||||||
|
"token": refreshToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создать новую пару токенов
|
||||||
|
return s.generateTokenPair(user.ID.Hex(), userAgent, ipAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeRefreshToken revokes a specific refresh token
|
||||||
|
func (s *AuthService) RevokeRefreshToken(userID, refreshToken string) error {
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = collection.UpdateOne(
|
||||||
|
context.Background(),
|
||||||
|
bson.M{"_id": objectID},
|
||||||
|
bson.M{
|
||||||
|
"$pull": bson.M{
|
||||||
|
"refreshTokens": bson.M{
|
||||||
|
"token": refreshToken,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeAllRefreshTokens revokes all refresh tokens for a user
|
||||||
|
func (s *AuthService) RevokeAllRefreshTokens(userID string) error {
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = collection.UpdateOne(
|
||||||
|
context.Background(),
|
||||||
|
bson.M{"_id": objectID},
|
||||||
|
bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"refreshTokens": []models.RefreshToken{},
|
||||||
|
"updatedAt": time.Now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyEmail verifies a user's email with a code.
|
||||||
|
func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) {
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Verified {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Email already verified",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) {
|
||||||
|
return nil, errors.New("invalid or expired verification code")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = collection.UpdateOne(
|
||||||
|
context.Background(),
|
||||||
|
bson.M{"email": req.Email},
|
||||||
|
bson.M{
|
||||||
|
"$set": bson.M{"verified": true},
|
||||||
|
"$unset": bson.M{
|
||||||
|
"verificationCode": "",
|
||||||
|
"verificationExpires": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Email verified successfully",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResendVerificationCode sends a new verification email.
|
||||||
|
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
|
||||||
|
collection := s.db.Collection("users")
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Verified {
|
||||||
|
return nil, errors.New("email already verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
code := s.generateVerificationCode()
|
||||||
|
codeExpires := time.Now().Add(10 * time.Minute)
|
||||||
|
|
||||||
|
_, err = collection.UpdateOne(
|
||||||
|
context.Background(),
|
||||||
|
bson.M{"email": req.Email},
|
||||||
|
bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"verificationCode": code,
|
||||||
|
"verificationExpires": codeExpires,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.emailService != nil {
|
||||||
|
go s.emailService.SendVerificationEmail(user.Email, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Verification code sent to your email",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAccount deletes a user and all associated data.
|
||||||
|
func (s *AuthService) DeleteAccount(ctx context.Context, userID string) error {
|
||||||
|
objectID, err := primitive.ObjectIDFromHex(userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid user ID format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Find user reactions and remove them from cub.rip
|
||||||
|
if s.baseURL != "" { // Changed from cubAPIURL to baseURL
|
||||||
|
reactionsCollection := s.db.Collection("reactions")
|
||||||
|
var userReactions []Reaction
|
||||||
|
cursor, err := reactionsCollection.Find(ctx, bson.M{"userId": objectID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to find user reactions: %w", err)
|
||||||
|
}
|
||||||
|
if err = cursor.All(ctx, &userReactions); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode user reactions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
for _, reaction := range userReactions {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(r Reaction) {
|
||||||
|
defer wg.Done()
|
||||||
|
url := fmt.Sprintf("%s/reactions/remove/%s/%s", s.baseURL, r.MediaID, r.Type) // Changed from cubAPIURL to baseURL
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", url, nil) // or "DELETE"
|
||||||
|
if err != nil {
|
||||||
|
// Log the error but don't stop the process
|
||||||
|
fmt.Printf("failed to create request for cub.rip: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("failed to send request to cub.rip: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("cub.rip API responded with status %d: %s\n", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}(reaction)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Delete all user-related data from the database
|
||||||
|
usersCollection := s.db.Collection("users")
|
||||||
|
favoritesCollection := s.db.Collection("favorites")
|
||||||
|
reactionsCollection := s.db.Collection("reactions")
|
||||||
|
|
||||||
|
_, err = usersCollection.DeleteOne(ctx, bson.M{"_id": objectID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = favoritesCollection.DeleteMany(ctx, bson.M{"userId": objectID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user favorites: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = reactionsCollection.DeleteMany(ctx, bson.M{"userId": objectID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user reactions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
150
pkg/services/email.go
Normal file
150
pkg/services/email.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EmailService struct {
|
||||||
|
config *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailService(cfg *config.Config) *EmailService {
|
||||||
|
return &EmailService{
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailOptions struct {
|
||||||
|
To []string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
IsHTML bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailService) SendEmail(options *EmailOptions) error {
|
||||||
|
if s.config.GmailUser == "" || s.config.GmailPassword == "" {
|
||||||
|
return fmt.Errorf("Gmail credentials not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gmail SMTP конфигурация
|
||||||
|
smtpHost := "smtp.gmail.com"
|
||||||
|
smtpPort := "587"
|
||||||
|
auth := smtp.PlainAuth("", s.config.GmailUser, s.config.GmailPassword, smtpHost)
|
||||||
|
|
||||||
|
// Создаем заголовки email
|
||||||
|
headers := make(map[string]string)
|
||||||
|
headers["From"] = s.config.GmailUser
|
||||||
|
headers["To"] = strings.Join(options.To, ",")
|
||||||
|
headers["Subject"] = options.Subject
|
||||||
|
|
||||||
|
if options.IsHTML {
|
||||||
|
headers["MIME-Version"] = "1.0"
|
||||||
|
headers["Content-Type"] = "text/html; charset=UTF-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем сообщение
|
||||||
|
message := ""
|
||||||
|
for key, value := range headers {
|
||||||
|
message += fmt.Sprintf("%s: %s\r\n", key, value)
|
||||||
|
}
|
||||||
|
message += "\r\n" + options.Body
|
||||||
|
|
||||||
|
// Отправляем email
|
||||||
|
err := smtp.SendMail(
|
||||||
|
smtpHost+":"+smtpPort,
|
||||||
|
auth,
|
||||||
|
s.config.GmailUser,
|
||||||
|
options.To,
|
||||||
|
[]byte(message),
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Предустановленные шаблоны email
|
||||||
|
func (s *EmailService) SendVerificationEmail(userEmail, code string) error {
|
||||||
|
options := &EmailOptions{
|
||||||
|
To: []string{userEmail},
|
||||||
|
Subject: "Подтверждение регистрации Neo Movies",
|
||||||
|
Body: fmt.Sprintf(`
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h1 style="color: #2196f3;">Neo Movies</h1>
|
||||||
|
<p>Здравствуйте!</p>
|
||||||
|
<p>Для завершения регистрации введите этот код:</p>
|
||||||
|
<div style="
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
margin: 20px 0;
|
||||||
|
">
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
<p>Код действителен в течение 10 минут.</p>
|
||||||
|
<p>Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо.</p>
|
||||||
|
</div>
|
||||||
|
`, code),
|
||||||
|
IsHTML: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.SendEmail(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailService) SendPasswordResetEmail(userEmail, resetToken string) error {
|
||||||
|
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, resetToken)
|
||||||
|
|
||||||
|
options := &EmailOptions{
|
||||||
|
To: []string{userEmail},
|
||||||
|
Subject: "Сброс пароля Neo Movies",
|
||||||
|
Body: fmt.Sprintf(`
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Сброс пароля</h2>
|
||||||
|
<p>Вы запросили сброс пароля для вашего аккаунта Neo Movies.</p>
|
||||||
|
<p>Нажмите на ссылку ниже, чтобы создать новый пароль:</p>
|
||||||
|
<p><a href="%s">Сбросить пароль</a></p>
|
||||||
|
<p>Ссылка действительна в течение 1 часа.</p>
|
||||||
|
<p>Если вы не запрашивали сброс пароля, проигнорируйте это сообщение.</p>
|
||||||
|
<br>
|
||||||
|
<p>С уважением,<br>Команда Neo Movies</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`, resetURL),
|
||||||
|
IsHTML: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.SendEmail(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailService) SendMovieRecommendationEmail(userEmail, userName string, movies []string) error {
|
||||||
|
moviesList := ""
|
||||||
|
for _, movie := range movies {
|
||||||
|
moviesList += fmt.Sprintf("<li>%s</li>", movie)
|
||||||
|
}
|
||||||
|
|
||||||
|
options := &EmailOptions{
|
||||||
|
To: []string{userEmail},
|
||||||
|
Subject: "Новые рекомендации фильмов от Neo Movies",
|
||||||
|
Body: fmt.Sprintf(`
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Привет, %s!</h2>
|
||||||
|
<p>У нас есть новые рекомендации фильмов специально для вас:</p>
|
||||||
|
<ul>%s</ul>
|
||||||
|
<p>Заходите в приложение, чтобы узнать больше деталей!</p>
|
||||||
|
<br>
|
||||||
|
<p>С уважением,<br>Команда Neo Movies</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`, userName, moviesList),
|
||||||
|
IsHTML: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.SendEmail(options)
|
||||||
|
}
|
||||||
184
pkg/services/favorites.go
Normal file
184
pkg/services/favorites.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FavoritesService struct {
|
||||||
|
db *mongo.Database
|
||||||
|
tmdb *TMDBService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFavoritesService(db *mongo.Database, tmdb *TMDBService) *FavoritesService {
|
||||||
|
return &FavoritesService{
|
||||||
|
db: db,
|
||||||
|
tmdb: tmdb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FavoritesService) AddToFavorites(userID, mediaID, mediaType string) error {
|
||||||
|
collection := s.db.Collection("favorites")
|
||||||
|
|
||||||
|
// Проверяем, не добавлен ли уже в избранное
|
||||||
|
filter := bson.M{
|
||||||
|
"userId": userID,
|
||||||
|
"mediaId": mediaID,
|
||||||
|
"mediaType": mediaType,
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingFavorite models.Favorite
|
||||||
|
err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite)
|
||||||
|
if err == nil {
|
||||||
|
// Уже в избранном
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var title, posterPath string
|
||||||
|
|
||||||
|
// Получаем информацию из TMDB в зависимости от типа медиа
|
||||||
|
mediaIDInt, err := strconv.Atoi(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid media ID: %s", mediaID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mediaType == "movie" {
|
||||||
|
movie, err := s.tmdb.GetMovie(mediaIDInt, "en-US")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
title = movie.Title
|
||||||
|
posterPath = movie.PosterPath
|
||||||
|
} else if mediaType == "tv" {
|
||||||
|
tv, err := s.tmdb.GetTVShow(mediaIDInt, "en-US")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
title = tv.Name
|
||||||
|
posterPath = tv.PosterPath
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("invalid media type: %s", mediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем полный URL для постера
|
||||||
|
if posterPath != "" {
|
||||||
|
posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
favorite := models.Favorite{
|
||||||
|
UserID: userID,
|
||||||
|
MediaID: mediaID,
|
||||||
|
MediaType: mediaType,
|
||||||
|
Title: title,
|
||||||
|
PosterPath: posterPath,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = collection.InsertOne(context.Background(), favorite)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddToFavoritesWithInfo adds media to favorites with provided media information
|
||||||
|
func (s *FavoritesService) AddToFavoritesWithInfo(userID, mediaID, mediaType string, mediaInfo *models.MediaInfo) error {
|
||||||
|
collection := s.db.Collection("favorites")
|
||||||
|
|
||||||
|
// Проверяем, не добавлен ли уже в избранное
|
||||||
|
filter := bson.M{
|
||||||
|
"userId": userID,
|
||||||
|
"mediaId": mediaID,
|
||||||
|
"mediaType": mediaType,
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingFavorite models.Favorite
|
||||||
|
err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite)
|
||||||
|
if err == nil {
|
||||||
|
// Уже в избранном
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем полный URL для постера если он есть
|
||||||
|
posterPath := mediaInfo.PosterPath
|
||||||
|
if posterPath != "" && posterPath[0] == '/' {
|
||||||
|
posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
favorite := models.Favorite{
|
||||||
|
UserID: userID,
|
||||||
|
MediaID: mediaID,
|
||||||
|
MediaType: mediaType,
|
||||||
|
Title: mediaInfo.Title,
|
||||||
|
PosterPath: posterPath,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = collection.InsertOne(context.Background(), favorite)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FavoritesService) RemoveFromFavorites(userID, mediaID, mediaType string) error {
|
||||||
|
collection := s.db.Collection("favorites")
|
||||||
|
|
||||||
|
filter := bson.M{
|
||||||
|
"userId": userID,
|
||||||
|
"mediaId": mediaID,
|
||||||
|
"mediaType": mediaType,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := collection.DeleteOne(context.Background(), filter)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FavoritesService) GetFavorites(userID string) ([]models.Favorite, error) {
|
||||||
|
collection := s.db.Collection("favorites")
|
||||||
|
|
||||||
|
filter := bson.M{
|
||||||
|
"userId": userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor, err := collection.Find(context.Background(), filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(context.Background())
|
||||||
|
|
||||||
|
var favorites []models.Favorite
|
||||||
|
err = cursor.All(context.Background(), &favorites)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаем пустой массив вместо nil если нет избранных
|
||||||
|
if favorites == nil {
|
||||||
|
favorites = []models.Favorite{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return favorites, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FavoritesService) IsFavorite(userID, mediaID, mediaType string) (bool, error) {
|
||||||
|
collection := s.db.Collection("favorites")
|
||||||
|
|
||||||
|
filter := bson.M{
|
||||||
|
"userId": userID,
|
||||||
|
"mediaId": mediaID,
|
||||||
|
"mediaType": mediaType,
|
||||||
|
}
|
||||||
|
|
||||||
|
var favorite models.Favorite
|
||||||
|
err := collection.FindOne(context.Background(), filter).Decode(&favorite)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
53
pkg/services/movie.go
Normal file
53
pkg/services/movie.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MovieService struct {
|
||||||
|
tmdb *TMDBService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService {
|
||||||
|
return &MovieService{
|
||||||
|
tmdb: tmdb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MovieService) Search(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
|
||||||
|
return s.tmdb.SearchMovies(query, page, language, region, year)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MovieService) GetByID(id int, language string) (*models.Movie, error) {
|
||||||
|
return s.tmdb.GetMovie(id, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MovieService) GetPopular(page int, language, region string) (*models.TMDBResponse, error) {
|
||||||
|
return s.tmdb.GetPopularMovies(page, language, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MovieService) GetTopRated(page int, language, region string) (*models.TMDBResponse, error) {
|
||||||
|
return s.tmdb.GetTopRatedMovies(page, language, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MovieService) GetUpcoming(page int, language, region string) (*models.TMDBResponse, error) {
|
||||||
|
return s.tmdb.GetUpcomingMovies(page, language, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MovieService) GetNowPlaying(page int, language, region string) (*models.TMDBResponse, error) {
|
||||||
|
return s.tmdb.GetNowPlayingMovies(page, language, region)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MovieService) GetRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
|
||||||
|
return s.tmdb.GetMovieRecommendations(id, page, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBResponse, error) {
|
||||||
|
return s.tmdb.GetSimilarMovies(id, page, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||||
|
return s.tmdb.GetMovieExternalIDs(id)
|
||||||
|
}
|
||||||
190
pkg/services/reactions.go
Normal file
190
pkg/services/reactions.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/bson"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/mongo/options"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/config"
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReactionsService struct {
|
||||||
|
db *mongo.Database
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReactionsService(db *mongo.Database) *ReactionsService {
|
||||||
|
return &ReactionsService{
|
||||||
|
db: db,
|
||||||
|
client: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var validReactions = []string{"fire", "nice", "think", "bore", "shit"}
|
||||||
|
|
||||||
|
// Получить счетчики реакций для медиа из внешнего API (cub.rip)
|
||||||
|
func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) {
|
||||||
|
cubID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||||
|
|
||||||
|
resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", config.CubAPIBaseURL, cubID))
|
||||||
|
if err != nil {
|
||||||
|
return &models.ReactionCounts{}, nil
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return &models.ReactionCounts{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return &models.ReactionCounts{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Result []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Counter int `json:"counter"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &response); err != nil {
|
||||||
|
return &models.ReactionCounts{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := &models.ReactionCounts{}
|
||||||
|
for _, reaction := range response.Result {
|
||||||
|
switch reaction.Type {
|
||||||
|
case "fire":
|
||||||
|
counts.Fire = reaction.Counter
|
||||||
|
case "nice":
|
||||||
|
counts.Nice = reaction.Counter
|
||||||
|
case "think":
|
||||||
|
counts.Think = reaction.Counter
|
||||||
|
case "bore":
|
||||||
|
counts.Bore = reaction.Counter
|
||||||
|
case "shit":
|
||||||
|
counts.Shit = reaction.Counter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReactionsService) GetMyReaction(userID, mediaType, mediaID string) (string, error) {
|
||||||
|
collection := s.db.Collection("reactions")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Type string `bson:"type"`
|
||||||
|
}
|
||||||
|
err := collection.FindOne(ctx, bson.M{
|
||||||
|
"userId": userID,
|
||||||
|
"mediaType": mediaType,
|
||||||
|
"mediaId": mediaID,
|
||||||
|
}).Decode(&result)
|
||||||
|
if err != nil {
|
||||||
|
if err == mongo.ErrNoDocuments {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.Type, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReactionsService) SetReaction(userID, mediaType, mediaID, reactionType string) error {
|
||||||
|
if !s.isValidReactionType(reactionType) {
|
||||||
|
return fmt.Errorf("invalid reaction type")
|
||||||
|
}
|
||||||
|
|
||||||
|
collection := s.db.Collection("reactions")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := collection.UpdateOne(
|
||||||
|
ctx,
|
||||||
|
bson.M{"userId": userID, "mediaType": mediaType, "mediaId": mediaID},
|
||||||
|
bson.M{"$set": bson.M{"type": reactionType, "updatedAt": time.Now()}},
|
||||||
|
options.Update().SetUpsert(true),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
go s.sendReactionToCub(fmt.Sprintf("%s_%s", mediaType, mediaID), reactionType)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReactionsService) RemoveReaction(userID, mediaType, mediaID string) error {
|
||||||
|
collection := s.db.Collection("reactions")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := collection.DeleteOne(ctx, bson.M{
|
||||||
|
"userId": userID,
|
||||||
|
"mediaType": mediaType,
|
||||||
|
"mediaId": mediaID,
|
||||||
|
})
|
||||||
|
|
||||||
|
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
|
||||||
|
go s.sendReactionToCub(fullMediaID, "remove")
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить все реакции пользователя
|
||||||
|
func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models.Reaction, error) {
|
||||||
|
collection := s.db.Collection("reactions")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cursor, err := collection.Find(ctx, bson.M{"userId": userID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var reactions []models.Reaction
|
||||||
|
if err := cursor.All(ctx, &reactions); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reactions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReactionsService) isValidReactionType(reactionType string) bool {
|
||||||
|
for _, valid := range validReactions {
|
||||||
|
if valid == reactionType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправка реакции в cub.rip API (асинхронно)
|
||||||
|
func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
|
||||||
|
url := fmt.Sprintf("%s/reactions/set", config.CubAPIBaseURL)
|
||||||
|
|
||||||
|
data := map[string]string{
|
||||||
|
"mediaId": mediaID,
|
||||||
|
"type": reactionType,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType)
|
||||||
|
}
|
||||||
|
}
|
||||||
515
pkg/services/tmdb.go
Normal file
515
pkg/services/tmdb.go
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TMDBService struct {
|
||||||
|
accessToken string
|
||||||
|
baseURL string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTMDBService(accessToken string) *TMDBService {
|
||||||
|
return &TMDBService{
|
||||||
|
accessToken: accessToken,
|
||||||
|
baseURL: "https://api.themoviedb.org/3",
|
||||||
|
client: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) makeRequest(endpoint string, target interface{}) error {
|
||||||
|
req, err := http.NewRequest("GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем Bearer токен вместо API key в query параметрах
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.accessToken)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("TMDB API error: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.NewDecoder(resp.Body).Decode(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) SearchMovies(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("query", query)
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
params.Set("include_adult", "false")
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
if region != "" {
|
||||||
|
params.Set("region", region)
|
||||||
|
}
|
||||||
|
|
||||||
|
if year > 0 {
|
||||||
|
params.Set("year", strconv.Itoa(year))
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/search/movie?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) SearchMulti(query string, page int, language string) (*models.MultiSearchResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("query", query)
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
params.Set("include_adult", "false")
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/search/multi?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.MultiSearchResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтруем результаты: убираем "person", и без названия
|
||||||
|
filteredResults := make([]models.MultiSearchResult, 0)
|
||||||
|
for _, result := range response.Results {
|
||||||
|
if result.MediaType == "person" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTitle := false
|
||||||
|
if result.MediaType == "movie" && result.Title != "" {
|
||||||
|
hasTitle = true
|
||||||
|
} else if result.MediaType == "tv" && result.Name != "" {
|
||||||
|
hasTitle = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasTitle {
|
||||||
|
filteredResults = append(filteredResults, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Results = filteredResults
|
||||||
|
response.TotalResults = len(filteredResults)
|
||||||
|
|
||||||
|
return &response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Алиас для совместимости с новым WebTorrent handler
|
||||||
|
func (s *TMDBService) SearchTV(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
|
||||||
|
return s.SearchTVShows(query, page, language, firstAirDateYear)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("query", query)
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
params.Set("include_adult", "false")
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstAirDateYear > 0 {
|
||||||
|
params.Set("first_air_date_year", strconv.Itoa(firstAirDateYear))
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/search/tv?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBTVResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode())
|
||||||
|
|
||||||
|
var movie models.Movie
|
||||||
|
err := s.makeRequest(endpoint, &movie)
|
||||||
|
return &movie, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode())
|
||||||
|
|
||||||
|
var tvShow models.TVShow
|
||||||
|
err := s.makeRequest(endpoint, &tvShow)
|
||||||
|
return &tvShow, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/genre/%s/list?%s", s.baseURL, mediaType, params.Encode())
|
||||||
|
|
||||||
|
var response models.GenresResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) {
|
||||||
|
// Получаем жанры фильмов
|
||||||
|
movieGenres, err := s.GetGenres("movie", "ru-RU")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем жанры сериалов
|
||||||
|
tvGenres, err := s.GetGenres("tv", "ru-RU")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Объединяем жанры, убирая дубликаты
|
||||||
|
allGenres := make(map[int]models.Genre)
|
||||||
|
|
||||||
|
for _, genre := range movieGenres.Genres {
|
||||||
|
allGenres[genre.ID] = genre
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, genre := range tvGenres.Genres {
|
||||||
|
allGenres[genre.ID] = genre
|
||||||
|
}
|
||||||
|
|
||||||
|
// Преобразуем обратно в слайс
|
||||||
|
var genres []models.Genre
|
||||||
|
for _, genre := range allGenres {
|
||||||
|
genres = append(genres, genre)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &models.GenresResponse{Genres: genres}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetPopularMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
if region != "" {
|
||||||
|
params.Set("region", region)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/movie/popular?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
if region != "" {
|
||||||
|
params.Set("region", region)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/movie/top_rated?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
if region != "" {
|
||||||
|
params.Set("region", region)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/movie/upcoming?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*models.TMDBResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
if region != "" {
|
||||||
|
params.Set("region", region)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/movie/now_playing?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/movie/%d/recommendations?%s", s.baseURL, id, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.TMDBResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/movie/%d/similar?%s", s.baseURL, id, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBTVResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBTVResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/tv/on_the_air?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBTVResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBTVResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/tv/%d/recommendations?%s", s.baseURL, id, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBTVResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/tv/%d/similar?%s", s.baseURL, id, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBTVResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||||
|
endpoint := fmt.Sprintf("%s/movie/%d/external_ids", s.baseURL, id)
|
||||||
|
|
||||||
|
var ids models.ExternalIDs
|
||||||
|
err := s.makeRequest(endpoint, &ids)
|
||||||
|
return &ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetTVExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||||
|
endpoint := fmt.Sprintf("%s/tv/%d/external_ids", s.baseURL, id)
|
||||||
|
|
||||||
|
var ids models.ExternalIDs
|
||||||
|
err := s.makeRequest(endpoint, &ids)
|
||||||
|
return &ids, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) (*models.TMDBResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
params.Set("with_genres", strconv.Itoa(genreID))
|
||||||
|
params.Set("sort_by", "popularity.desc")
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/discover/movie?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) DiscoverTVByGenre(genreID, page int, language string) (*models.TMDBResponse, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("page", strconv.Itoa(page))
|
||||||
|
params.Set("with_genres", strconv.Itoa(genreID))
|
||||||
|
params.Set("sort_by", "popularity.desc")
|
||||||
|
|
||||||
|
if language != "" {
|
||||||
|
params.Set("language", language)
|
||||||
|
} else {
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/discover/tv?%s", s.baseURL, params.Encode())
|
||||||
|
|
||||||
|
var response models.TMDBResponse
|
||||||
|
err := s.makeRequest(endpoint, &response)
|
||||||
|
return &response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TMDBService) GetTVSeason(tvID, seasonNumber int, language string) (*models.SeasonDetails, error) {
|
||||||
|
if language == "" {
|
||||||
|
language = "ru-RU"
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("%s/tv/%d/season/%d?language=%s", s.baseURL, tvID, seasonNumber, language)
|
||||||
|
|
||||||
|
var season models.SeasonDetails
|
||||||
|
err := s.makeRequest(endpoint, &season)
|
||||||
|
return &season, err
|
||||||
|
}
|
||||||
935
pkg/services/torrent.go
Normal file
935
pkg/services/torrent.go
Normal file
@@ -0,0 +1,935 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TorrentService struct {
|
||||||
|
client *http.Client
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTorrentServiceWithConfig(baseURL, apiKey string) *TorrentService {
|
||||||
|
return &TorrentService{
|
||||||
|
client: &http.Client{Timeout: 8 * time.Second},
|
||||||
|
baseURL: baseURL,
|
||||||
|
apiKey: apiKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTorrentService() *TorrentService {
|
||||||
|
return &TorrentService{
|
||||||
|
client: &http.Client{Timeout: 8 * time.Second},
|
||||||
|
baseURL: "http://redapi.cfhttp.top",
|
||||||
|
apiKey: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTorrents - основной метод поиска торрентов через RedAPI
|
||||||
|
func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) {
|
||||||
|
searchParams := url.Values{}
|
||||||
|
|
||||||
|
for key, value := range params {
|
||||||
|
if value != "" {
|
||||||
|
if key == "category" {
|
||||||
|
searchParams.Add("category[]", value)
|
||||||
|
} else {
|
||||||
|
searchParams.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.apiKey != "" {
|
||||||
|
searchParams.Add("apikey", s.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
searchURL := fmt.Sprintf("%s/api/v2.0/indexers/all/results?%s", s.baseURL, searchParams.Encode())
|
||||||
|
|
||||||
|
resp, err := s.client.Get(searchURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to search torrents: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var redAPIResponse models.RedAPIResponse
|
||||||
|
if err := json.Unmarshal(body, &redAPIResponse); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := s.parseRedAPIResults(redAPIResponse)
|
||||||
|
|
||||||
|
return &models.TorrentSearchResponse{
|
||||||
|
Query: params["query"],
|
||||||
|
Results: results,
|
||||||
|
Total: len(results),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRedAPIResults преобразует результаты RedAPI в наш формат
|
||||||
|
func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models.TorrentResult {
|
||||||
|
var results []models.TorrentResult
|
||||||
|
|
||||||
|
for _, torrent := range data.Results {
|
||||||
|
var sizeStr string
|
||||||
|
switch v := torrent.Size.(type) {
|
||||||
|
case string:
|
||||||
|
sizeStr = v
|
||||||
|
case float64:
|
||||||
|
sizeStr = fmt.Sprintf("%.0f", v)
|
||||||
|
case int:
|
||||||
|
sizeStr = fmt.Sprintf("%d", v)
|
||||||
|
default:
|
||||||
|
sizeStr = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
result := models.TorrentResult{
|
||||||
|
Title: torrent.Title,
|
||||||
|
Tracker: torrent.Tracker,
|
||||||
|
Size: sizeStr,
|
||||||
|
Seeders: torrent.Seeders,
|
||||||
|
Peers: torrent.Peers,
|
||||||
|
MagnetLink: torrent.MagnetUri,
|
||||||
|
PublishDate: torrent.PublishDate,
|
||||||
|
Category: torrent.CategoryDesc,
|
||||||
|
Details: torrent.Details,
|
||||||
|
Source: "RedAPI",
|
||||||
|
}
|
||||||
|
|
||||||
|
if torrent.Info != nil {
|
||||||
|
switch v := torrent.Info.Quality.(type) {
|
||||||
|
case string:
|
||||||
|
result.Quality = v
|
||||||
|
case float64:
|
||||||
|
result.Quality = fmt.Sprintf("%.0fp", v)
|
||||||
|
case int:
|
||||||
|
result.Quality = fmt.Sprintf("%dp", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Voice = torrent.Info.Voices
|
||||||
|
result.Types = torrent.Info.Types
|
||||||
|
result.Seasons = torrent.Info.Seasons
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Quality == "" {
|
||||||
|
result.Quality = s.ExtractQuality(result.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
|
||||||
|
func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) {
|
||||||
|
title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get title from TMDB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]string{
|
||||||
|
"imdb": imdbID,
|
||||||
|
"query": title,
|
||||||
|
"title_original": originalTitle,
|
||||||
|
"year": year,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mediaType {
|
||||||
|
case "movie":
|
||||||
|
params["is_serial"] = "1"
|
||||||
|
params["category"] = "2000"
|
||||||
|
case "serial", "series", "tv":
|
||||||
|
params["is_serial"] = "2"
|
||||||
|
params["category"] = "5000"
|
||||||
|
case "anime":
|
||||||
|
params["is_serial"] = "5"
|
||||||
|
params["category"] = "5070"
|
||||||
|
}
|
||||||
|
|
||||||
|
if options != nil && options.Season != nil && *options.Season > 0 {
|
||||||
|
params["season"] = strconv.Itoa(*options.Season)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.SearchTorrents(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if options != nil {
|
||||||
|
response.Results = s.FilterByContentType(response.Results, options.ContentType)
|
||||||
|
response.Results = s.FilterTorrents(response.Results, options)
|
||||||
|
response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder)
|
||||||
|
}
|
||||||
|
response.Total = len(response.Results)
|
||||||
|
|
||||||
|
if len(response.Results) < 5 && (mediaType == "serial" || mediaType == "series" || mediaType == "tv") && options != nil && options.Season != nil {
|
||||||
|
paramsNoSeason := map[string]string{
|
||||||
|
"imdb": imdbID,
|
||||||
|
"query": title,
|
||||||
|
"title_original": originalTitle,
|
||||||
|
"year": year,
|
||||||
|
"is_serial": "2",
|
||||||
|
"category": "5000",
|
||||||
|
}
|
||||||
|
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
|
||||||
|
if err == nil {
|
||||||
|
filtered := s.filterBySeason(fallbackResp.Results, *options.Season)
|
||||||
|
all := append(response.Results, filtered...)
|
||||||
|
unique := make([]models.TorrentResult, 0, len(all))
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, t := range all {
|
||||||
|
if !seen[t.MagnetLink] {
|
||||||
|
unique = append(unique, t)
|
||||||
|
seen[t.MagnetLink] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.Results = unique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchMovies - поиск фильмов с дополнительной фильтрацией
|
||||||
|
func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
|
||||||
|
params := map[string]string{
|
||||||
|
"title": title,
|
||||||
|
"title_original": originalTitle,
|
||||||
|
"year": year,
|
||||||
|
"is_serial": "1",
|
||||||
|
"category": "2000",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.SearchTorrents(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Results = s.FilterByContentType(response.Results, "movie")
|
||||||
|
response.Total = len(response.Results)
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchSeries - поиск сериалов с поддержкой fallback и фильтрации по сезону
|
||||||
|
func (s *TorrentService) SearchSeries(title, originalTitle, year string, season *int) (*models.TorrentSearchResponse, error) {
|
||||||
|
params := map[string]string{
|
||||||
|
"title": title,
|
||||||
|
"title_original": originalTitle,
|
||||||
|
"year": year,
|
||||||
|
"is_serial": "2",
|
||||||
|
"category": "5000",
|
||||||
|
}
|
||||||
|
if season != nil {
|
||||||
|
params["season"] = strconv.Itoa(*season)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.SearchTorrents(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если указан сезон и результатов мало, делаем fallback-поиск без сезона и фильтруем на клиенте
|
||||||
|
if season != nil && len(response.Results) < 5 {
|
||||||
|
paramsNoSeason := map[string]string{
|
||||||
|
"title": title,
|
||||||
|
"title_original": originalTitle,
|
||||||
|
"year": year,
|
||||||
|
"is_serial": "2",
|
||||||
|
"category": "5000",
|
||||||
|
}
|
||||||
|
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
|
||||||
|
if err == nil {
|
||||||
|
filtered := s.filterBySeason(fallbackResp.Results, *season)
|
||||||
|
// Объединяем и убираем дубликаты по MagnetLink
|
||||||
|
all := append(response.Results, filtered...)
|
||||||
|
unique := make([]models.TorrentResult, 0, len(all))
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, t := range all {
|
||||||
|
if !seen[t.MagnetLink] {
|
||||||
|
unique = append(unique, t)
|
||||||
|
seen[t.MagnetLink] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.Results = unique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Results = s.FilterByContentType(response.Results, "serial")
|
||||||
|
response.Total = len(response.Results)
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterBySeason - фильтрация результатов по сезону (аналогично JS)
|
||||||
|
func (s *TorrentService) filterBySeason(results []models.TorrentResult, season int) []models.TorrentResult {
|
||||||
|
if season == 0 {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
filtered := make([]models.TorrentResult, 0, len(results))
|
||||||
|
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||||
|
for _, torrent := range results {
|
||||||
|
found := false
|
||||||
|
// Проверяем поле seasons
|
||||||
|
for _, s := range torrent.Seasons {
|
||||||
|
if s == season {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Проверяем в названии
|
||||||
|
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
seasonNumber := 0
|
||||||
|
if match[1] != "" {
|
||||||
|
seasonNumber, _ = strconv.Atoi(match[1])
|
||||||
|
} else if match[2] != "" {
|
||||||
|
seasonNumber, _ = strconv.Atoi(match[2])
|
||||||
|
}
|
||||||
|
if seasonNumber == season {
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAnime - поиск аниме
|
||||||
|
func (s *TorrentService) SearchAnime(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
|
||||||
|
params := map[string]string{
|
||||||
|
"title": title,
|
||||||
|
"title_original": originalTitle,
|
||||||
|
"year": year,
|
||||||
|
"is_serial": "5",
|
||||||
|
"category": "5070",
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := s.SearchTorrents(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Results = s.FilterByContentType(response.Results, "anime")
|
||||||
|
response.Total = len(response.Results)
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllohaResponse - структура ответа от Alloha API
|
||||||
|
type AllohaResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
OriginalName string `json:"original_name"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
Category int `json:"category"` // 1-фильм, 2-сериал
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMovieInfoByIMDB - получение информации через Alloha API (как в JavaScript версии)
|
||||||
|
func (s *TorrentService) getMovieInfoByIMDB(imdbID string) (string, string, string, error) {
|
||||||
|
// Используем тот же токен что и в JavaScript версии
|
||||||
|
endpoint := fmt.Sprintf("https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&imdb=%s", imdbID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var allohaResponse AllohaResponse
|
||||||
|
if err := json.Unmarshal(body, &allohaResponse); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if allohaResponse.Status != "success" {
|
||||||
|
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := allohaResponse.Data.Name
|
||||||
|
originalTitle := allohaResponse.Data.OriginalName
|
||||||
|
year := ""
|
||||||
|
if allohaResponse.Data.Year > 0 {
|
||||||
|
year = strconv.Itoa(allohaResponse.Data.Year)
|
||||||
|
}
|
||||||
|
|
||||||
|
return title, originalTitle, year, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTitleFromTMDB - получение информации из TMDB (с fallback на Alloha API)
|
||||||
|
func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, mediaType string) (string, string, string, error) {
|
||||||
|
// Сначала пробуем Alloha API (как в JavaScript версии)
|
||||||
|
title, originalTitle, year, err := s.getMovieInfoByIMDB(imdbID)
|
||||||
|
if err == nil {
|
||||||
|
return title, originalTitle, year, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если Alloha API не работает, пробуем TMDB API
|
||||||
|
endpoint := fmt.Sprintf("https://api.themoviedb.org/3/find/%s", imdbID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("external_source", "imdb_id")
|
||||||
|
params.Set("language", "ru-RU")
|
||||||
|
req.URL.RawQuery = params.Encode()
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tmdbService.accessToken)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := s.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var findResponse struct {
|
||||||
|
MovieResults []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
OriginalTitle string `json:"original_title"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
} `json:"movie_results"`
|
||||||
|
TVResults []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
OriginalName string `json:"original_name"`
|
||||||
|
FirstAirDate string `json:"first_air_date"`
|
||||||
|
} `json:"tv_results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &findResponse); err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if mediaType == "movie" && len(findResponse.MovieResults) > 0 {
|
||||||
|
movie := findResponse.MovieResults[0]
|
||||||
|
title := movie.Title
|
||||||
|
originalTitle := movie.OriginalTitle
|
||||||
|
year := ""
|
||||||
|
if movie.ReleaseDate != "" {
|
||||||
|
year = movie.ReleaseDate[:4]
|
||||||
|
}
|
||||||
|
return title, originalTitle, year, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaType == "tv" || mediaType == "series") && len(findResponse.TVResults) > 0 {
|
||||||
|
tv := findResponse.TVResults[0]
|
||||||
|
title := tv.Name
|
||||||
|
originalTitle := tv.OriginalName
|
||||||
|
year := ""
|
||||||
|
if tv.FirstAirDate != "" {
|
||||||
|
year = tv.FirstAirDate[:4]
|
||||||
|
}
|
||||||
|
return title, originalTitle, year, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterByContentType - фильтрация по типу контента (как в JS)
|
||||||
|
func (s *TorrentService) FilterByContentType(results []models.TorrentResult, contentType string) []models.TorrentResult {
|
||||||
|
if contentType == "" {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
var filtered []models.TorrentResult
|
||||||
|
for _, torrent := range results {
|
||||||
|
// Фильтрация по полю types, если оно есть
|
||||||
|
if len(torrent.Types) > 0 {
|
||||||
|
switch contentType {
|
||||||
|
case "movie":
|
||||||
|
if s.containsAny(torrent.Types, []string{"movie", "multfilm", "documovie"}) {
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
}
|
||||||
|
case "serial", "series", "tv":
|
||||||
|
if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) {
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
}
|
||||||
|
case "anime":
|
||||||
|
if s.contains(torrent.Types, "anime") {
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Фильтрация по названию, если types недоступно
|
||||||
|
title := strings.ToLower(torrent.Title)
|
||||||
|
switch contentType {
|
||||||
|
case "movie":
|
||||||
|
if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
}
|
||||||
|
case "serial", "series", "tv":
|
||||||
|
if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
}
|
||||||
|
case "anime":
|
||||||
|
if torrent.Category == "TV/Anime" || regexp.MustCompile(`(?i)anime`).MatchString(title) {
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterTorrents - фильтрация торрентов по опциям
|
||||||
|
func (s *TorrentService) FilterTorrents(torrents []models.TorrentResult, options *models.TorrentSearchOptions) []models.TorrentResult {
|
||||||
|
if options == nil {
|
||||||
|
return torrents
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []models.TorrentResult
|
||||||
|
|
||||||
|
for _, torrent := range torrents {
|
||||||
|
// Фильтрация по качеству
|
||||||
|
if len(options.Quality) > 0 {
|
||||||
|
found := false
|
||||||
|
for _, quality := range options.Quality {
|
||||||
|
if strings.EqualFold(torrent.Quality, quality) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация по минимальному качеству
|
||||||
|
if options.MinQuality != "" && !s.qualityMeetsMinimum(torrent.Quality, options.MinQuality) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация по максимальному качеству
|
||||||
|
if options.MaxQuality != "" && !s.qualityMeetsMaximum(torrent.Quality, options.MaxQuality) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Исключение качеств
|
||||||
|
if len(options.ExcludeQualities) > 0 {
|
||||||
|
excluded := false
|
||||||
|
for _, excludeQuality := range options.ExcludeQualities {
|
||||||
|
if strings.EqualFold(torrent.Quality, excludeQuality) {
|
||||||
|
excluded = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if excluded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация по HDR
|
||||||
|
if options.HDR != nil {
|
||||||
|
hasHDR := regexp.MustCompile(`(?i)(hdr|dolby.vision|dv)`).MatchString(torrent.Title)
|
||||||
|
if *options.HDR != hasHDR {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация по HEVC
|
||||||
|
if options.HEVC != nil {
|
||||||
|
hasHEVC := regexp.MustCompile(`(?i)(hevc|h\.265|x265)`).MatchString(torrent.Title)
|
||||||
|
if *options.HEVC != hasHEVC {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация по сезону (дополнительная на клиенте)
|
||||||
|
if options.Season != nil {
|
||||||
|
if !s.matchesSeason(torrent, *options.Season) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesSeason - проверка соответствия сезону
|
||||||
|
func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int) bool {
|
||||||
|
// Проверяем в поле seasons
|
||||||
|
for _, s := range torrent.Seasons {
|
||||||
|
if s == season {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем в названии
|
||||||
|
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||||
|
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
seasonNumber := 0
|
||||||
|
if match[1] != "" {
|
||||||
|
seasonNumber, _ = strconv.Atoi(match[1])
|
||||||
|
} else if match[2] != "" {
|
||||||
|
seasonNumber, _ = strconv.Atoi(match[2])
|
||||||
|
}
|
||||||
|
if seasonNumber == season {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractQuality - извлечение качества из названия
|
||||||
|
func (s *TorrentService) ExtractQuality(title string) string {
|
||||||
|
title = strings.ToUpper(title)
|
||||||
|
|
||||||
|
qualityPatterns := []struct {
|
||||||
|
pattern string
|
||||||
|
quality string
|
||||||
|
}{
|
||||||
|
{`2160P|4K`, "2160p"},
|
||||||
|
{`1440P`, "1440p"},
|
||||||
|
{`1080P`, "1080p"},
|
||||||
|
{`720P`, "720p"},
|
||||||
|
{`480P`, "480p"},
|
||||||
|
{`360P`, "360p"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, qp := range qualityPatterns {
|
||||||
|
if matched, _ := regexp.MatchString(qp.pattern, title); matched {
|
||||||
|
if qp.quality == "2160p" {
|
||||||
|
return "4K"
|
||||||
|
}
|
||||||
|
return qp.quality
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortTorrents - сортировка результатов
|
||||||
|
func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, sortOrder string) []models.TorrentResult {
|
||||||
|
if sortBy == "" {
|
||||||
|
sortBy = "seeders"
|
||||||
|
}
|
||||||
|
if sortOrder == "" {
|
||||||
|
sortOrder = "desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(torrents, func(i, j int) bool {
|
||||||
|
var less bool
|
||||||
|
|
||||||
|
switch sortBy {
|
||||||
|
case "seeders":
|
||||||
|
less = torrents[i].Seeders < torrents[j].Seeders
|
||||||
|
case "size":
|
||||||
|
less = s.compareSizes(torrents[i].Size, torrents[j].Size)
|
||||||
|
case "date":
|
||||||
|
t1, _ := time.Parse(time.RFC3339, torrents[i].PublishDate)
|
||||||
|
t2, _ := time.Parse(time.RFC3339, torrents[j].PublishDate)
|
||||||
|
less = t1.Before(t2)
|
||||||
|
default:
|
||||||
|
less = torrents[i].Seeders < torrents[j].Seeders
|
||||||
|
}
|
||||||
|
|
||||||
|
if sortOrder == "asc" {
|
||||||
|
return less
|
||||||
|
}
|
||||||
|
return !less
|
||||||
|
})
|
||||||
|
|
||||||
|
return torrents
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupByQuality - группировка по качеству
|
||||||
|
func (s *TorrentService) GroupByQuality(results []models.TorrentResult) map[string][]models.TorrentResult {
|
||||||
|
groups := make(map[string][]models.TorrentResult)
|
||||||
|
|
||||||
|
for _, torrent := range results {
|
||||||
|
quality := torrent.Quality
|
||||||
|
if quality == "" {
|
||||||
|
quality = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Объединяем 4K и 2160p в одну группу
|
||||||
|
if quality == "2160p" {
|
||||||
|
quality = "4K"
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[quality] = append(groups[quality], torrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем торренты внутри каждой группы по сидам
|
||||||
|
for quality := range groups {
|
||||||
|
sort.Slice(groups[quality], func(i, j int) bool {
|
||||||
|
return groups[quality][i].Seeders > groups[quality][j].Seeders
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupBySeason - группировка по сезонам
|
||||||
|
func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[string][]models.TorrentResult {
|
||||||
|
groups := make(map[string][]models.TorrentResult)
|
||||||
|
|
||||||
|
for _, torrent := range results {
|
||||||
|
seasons := make(map[int]bool)
|
||||||
|
|
||||||
|
// Извлекаем сезоны из поля seasons
|
||||||
|
for _, season := range torrent.Seasons {
|
||||||
|
seasons[season] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем сезоны из названия
|
||||||
|
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||||
|
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
seasonNumber := 0
|
||||||
|
if match[1] != "" {
|
||||||
|
seasonNumber, _ = strconv.Atoi(match[1])
|
||||||
|
} else if match[2] != "" {
|
||||||
|
seasonNumber, _ = strconv.Atoi(match[2])
|
||||||
|
}
|
||||||
|
if seasonNumber > 0 {
|
||||||
|
seasons[seasonNumber] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если сезоны не найдены, добавляем в группу "unknown"
|
||||||
|
if len(seasons) == 0 {
|
||||||
|
groups["Неизвестно"] = append(groups["Неизвестно"], torrent)
|
||||||
|
} else {
|
||||||
|
// Добавляем торрент во все соответствующие группы сезонов
|
||||||
|
for season := range seasons {
|
||||||
|
seasonKey := fmt.Sprintf("Сезон %d", season)
|
||||||
|
// Проверяем дубликаты
|
||||||
|
found := false
|
||||||
|
for _, existing := range groups[seasonKey] {
|
||||||
|
if existing.MagnetLink == torrent.MagnetLink {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
groups[seasonKey] = append(groups[seasonKey], torrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем торренты внутри каждой группы по сидам
|
||||||
|
for season := range groups {
|
||||||
|
sort.Slice(groups[season], func(i, j int) bool {
|
||||||
|
return groups[season][i].Seeders > groups[season][j].Seeders
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailableSeasons - получение доступных сезонов для сериала
|
||||||
|
func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string) ([]int, error) {
|
||||||
|
response, err := s.SearchSeries(title, originalTitle, year, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seasonsSet := make(map[int]bool)
|
||||||
|
|
||||||
|
for _, torrent := range response.Results {
|
||||||
|
// Извлекаем из поля seasons
|
||||||
|
for _, season := range torrent.Seasons {
|
||||||
|
seasonsSet[season] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем из названия
|
||||||
|
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
|
||||||
|
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
seasonNumber := 0
|
||||||
|
if match[1] != "" {
|
||||||
|
seasonNumber, _ = strconv.Atoi(match[1])
|
||||||
|
} else if match[2] != "" {
|
||||||
|
seasonNumber, _ = strconv.Atoi(match[2])
|
||||||
|
}
|
||||||
|
if seasonNumber > 0 {
|
||||||
|
seasonsSet[seasonNumber] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var seasons []int
|
||||||
|
for season := range seasonsSet {
|
||||||
|
seasons = append(seasons, season)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Ints(seasons)
|
||||||
|
return seasons, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchByImdb - поиск по IMDB ID (movie/serial/anime).
|
||||||
|
func (s *TorrentService) SearchByImdb(imdbID, contentType string, season *int) ([]models.TorrentResult, error) {
|
||||||
|
if imdbID == "" || !strings.HasPrefix(imdbID, "tt") {
|
||||||
|
return nil, fmt.Errorf("Неверный формат IMDB ID. Должен быть в формате tt1234567")
|
||||||
|
}
|
||||||
|
|
||||||
|
// НЕ добавляем title, originalTitle, year, чтобы запрос не был слишком строгим.
|
||||||
|
params := map[string]string{
|
||||||
|
"imdb": imdbID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем тип контента для API
|
||||||
|
switch contentType {
|
||||||
|
case "movie":
|
||||||
|
params["is_serial"] = "1"
|
||||||
|
params["category"] = "2000"
|
||||||
|
case "serial", "series", "tv":
|
||||||
|
params["is_serial"] = "2"
|
||||||
|
params["category"] = "5000"
|
||||||
|
case "anime":
|
||||||
|
params["is_serial"] = "5"
|
||||||
|
params["category"] = "5070"
|
||||||
|
default:
|
||||||
|
// Значение по умолчанию на случай неизвестного типа
|
||||||
|
params["is_serial"] = "1"
|
||||||
|
params["category"] = "2000"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Параметр season можно оставить, он полезен
|
||||||
|
if season != nil && *season > 0 {
|
||||||
|
params["season"] = strconv.Itoa(*season)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.SearchTorrents(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := resp.Results
|
||||||
|
|
||||||
|
// Fallback для сериалов: если указан сезон и результатов мало, ищем без сезона и фильтруем на клиенте
|
||||||
|
if s.contains([]string{"serial", "series", "tv"}, contentType) && season != nil && len(results) < 5 {
|
||||||
|
paramsNoSeason := map[string]string{
|
||||||
|
"imdb": imdbID,
|
||||||
|
"is_serial": "2",
|
||||||
|
"category": "5000",
|
||||||
|
}
|
||||||
|
|
||||||
|
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
|
||||||
|
if err == nil {
|
||||||
|
filtered := s.filterBySeason(fallbackResp.Results, *season)
|
||||||
|
// Объединяем и убираем дубликаты по MagnetLink
|
||||||
|
all := append(results, filtered...)
|
||||||
|
unique := make([]models.TorrentResult, 0, len(all))
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, t := range all {
|
||||||
|
if !seen[t.MagnetLink] {
|
||||||
|
unique = append(unique, t)
|
||||||
|
seen[t.MagnetLink] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = unique
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Финальная фильтрация по типу контента на стороне клиента для надежности
|
||||||
|
results = s.FilterByContentType(results, contentType)
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ############# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ #############
|
||||||
|
|
||||||
|
func (s *TorrentService) qualityMeetsMinimum(quality, minQuality string) bool {
|
||||||
|
qualityOrder := map[string]int{
|
||||||
|
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLevel, ok1 := qualityOrder[strings.ToLower(quality)]
|
||||||
|
minLevel, ok2 := qualityOrder[strings.ToLower(minQuality)]
|
||||||
|
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return true // Если качество не определено, не фильтруем
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentLevel >= minLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TorrentService) qualityMeetsMaximum(quality, maxQuality string) bool {
|
||||||
|
qualityOrder := map[string]int{
|
||||||
|
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLevel, ok1 := qualityOrder[strings.ToLower(quality)]
|
||||||
|
maxLevel, ok2 := qualityOrder[strings.ToLower(maxQuality)]
|
||||||
|
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return true // Если качество не определено, не фильтруем
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentLevel <= maxLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TorrentService) parseSize(sizeStr string) int64 {
|
||||||
|
val, err := strconv.ParseInt(sizeStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TorrentService) compareSizes(size1, size2 string) bool {
|
||||||
|
return s.parseSize(size1) < s.parseSize(size2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TorrentService) contains(slice []string, item string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if strings.EqualFold(s, item) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TorrentService) containsAny(slice []string, items []string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if s.contains(slice, item) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
55
pkg/services/tv.go
Normal file
55
pkg/services/tv.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.mongodb.org/mongo-driver/mongo"
|
||||||
|
|
||||||
|
"neomovies-api/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TVService struct {
|
||||||
|
db *mongo.Database
|
||||||
|
tmdb *TMDBService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTVService(db *mongo.Database, tmdb *TMDBService) *TVService {
|
||||||
|
return &TVService{
|
||||||
|
db: db,
|
||||||
|
tmdb: tmdb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TVService) Search(query string, page int, language string, year int) (*models.TMDBTVResponse, error) {
|
||||||
|
return s.tmdb.SearchTVShows(query, page, language, year)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TVService) GetByID(id int, language string) (*models.TVShow, error) {
|
||||||
|
return s.tmdb.GetTVShow(id, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TVService) GetPopular(page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
return s.tmdb.GetPopularTVShows(page, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TVService) GetTopRated(page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
return s.tmdb.GetTopRatedTVShows(page, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TVService) GetOnTheAir(page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
return s.tmdb.GetOnTheAirTVShows(page, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TVService) GetAiringToday(page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
return s.tmdb.GetAiringTodayTVShows(page, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TVService) GetRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
return s.tmdb.GetTVRecommendations(id, page, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVResponse, error) {
|
||||||
|
return s.tmdb.GetSimilarTVShows(id, page, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||||
|
return s.tmdb.GetTVExternalIDs(id)
|
||||||
|
}
|
||||||
13
render.yaml
13
render.yaml
@@ -1,13 +0,0 @@
|
|||||||
services:
|
|
||||||
- type: web
|
|
||||||
name: neomovies-api
|
|
||||||
env: go
|
|
||||||
buildCommand: go build -o app
|
|
||||||
startCommand: ./app
|
|
||||||
envVars:
|
|
||||||
- key: GIN_MODE
|
|
||||||
value: release
|
|
||||||
- key: TMDB_ACCESS_TOKEN
|
|
||||||
sync: false
|
|
||||||
healthCheckPath: /health
|
|
||||||
autoDeploy: true
|
|
||||||
7
run.sh
7
run.sh
@@ -1,7 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Переходим в директорию с приложением
|
|
||||||
cd "$HOME/neomovies-api"
|
|
||||||
|
|
||||||
# Запускаем приложение
|
|
||||||
PORT=$PORT GIN_MODE=release ./app
|
|
||||||
21
vercel.json
Normal file
21
vercel.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"builds": [
|
||||||
|
{
|
||||||
|
"src": "api/index.go",
|
||||||
|
"use": "@vercel/go",
|
||||||
|
"config": {
|
||||||
|
"maxDuration": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"src": "/(.*)",
|
||||||
|
"dest": "/api/index.go"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GO_VERSION": "1.21"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user