mirror of
https://gitlab.com/foxixus/neomovies-api.git
synced 2025-10-28 01:48:51 +05:00
Add WebTorrent Player(Experimental)
This commit is contained in:
271
WEBTORRENT_PLAYER.md
Normal file
271
WEBTORRENT_PLAYER.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# 🎬 NeoMovies WebTorrent Player
|
||||
|
||||
Современный плеер для просмотра торрент файлов прямо в браузере с умной интеграцией TMDB.
|
||||
|
||||
## 🚀 Особенности
|
||||
|
||||
- ✅ **Полностью клиентский** - все торренты обрабатываются в браузере
|
||||
- ✅ **Умная навигация** - автоматическое определение сезонов и серий
|
||||
- ✅ **TMDB интеграция** - красивые названия серий вместо имен файлов
|
||||
- ✅ **Мультиформат** - поддержка MP4, AVI, MKV, WebM и других
|
||||
- ✅ **Потоковое воспроизведение** - начинает играть до полной загрузки
|
||||
- ✅ **Прогресс загрузки** - отображение скорости и процента загрузки
|
||||
|
||||
## 📋 API Endpoints
|
||||
|
||||
### Открытие плеера
|
||||
```http
|
||||
GET /api/v1/webtorrent/player?magnet={MAGNET_LINK}
|
||||
```
|
||||
или через заголовок:
|
||||
```http
|
||||
GET /api/v1/webtorrent/player
|
||||
X-Magnet-Link: {MAGNET_LINK}
|
||||
```
|
||||
|
||||
### Получение метаданных
|
||||
```http
|
||||
GET /api/v1/webtorrent/metadata?query={SEARCH_QUERY}
|
||||
```
|
||||
|
||||
## 💻 Примеры использования
|
||||
|
||||
### 1. Простое открытие плеера
|
||||
```javascript
|
||||
const magnetLink = "magnet:?xt=urn:btih:...";
|
||||
const encodedMagnet = encodeURIComponent(magnetLink);
|
||||
window.open(`/api/v1/webtorrent/player?magnet=${encodedMagnet}`);
|
||||
```
|
||||
|
||||
### 2. Открытие через заголовок
|
||||
```javascript
|
||||
fetch('/api/v1/webtorrent/player', {
|
||||
headers: {
|
||||
'X-Magnet-Link': magnetLink
|
||||
}
|
||||
}).then(response => {
|
||||
// Открыть в новом окне
|
||||
window.open(URL.createObjectURL(response.blob()));
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Получение метаданных
|
||||
```javascript
|
||||
fetch('/api/v1/webtorrent/metadata?query=Breaking Bad')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Метаданные:', data.data);
|
||||
// data.data содержит информацию о сериале/фильме
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 🎮 Управление плеером
|
||||
|
||||
### Клавиши управления
|
||||
- **Space** - пауза/воспроизведение
|
||||
- **Click** - выбор серии/файла
|
||||
|
||||
### UI элементы
|
||||
- **Информация о медиа** - название, год, описание (верхний левый угол)
|
||||
- **Список файлов** - выбор серий/частей (нижняя панель)
|
||||
- **Информация о серии** - название и описание текущей серии
|
||||
- **Прогресс загрузки** - скорость и процент загрузки
|
||||
|
||||
## 🔧 Как это работает
|
||||
|
||||
### 1. Загрузка торрента
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Magnet Link] --> B[WebTorrent Client]
|
||||
B --> C[Parse Metadata]
|
||||
C --> D[Filter Video Files]
|
||||
D --> E[Display File List]
|
||||
```
|
||||
|
||||
### 2. Получение метаданных
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Torrent Name] --> B[Extract Title]
|
||||
B --> C[Search TMDB]
|
||||
C --> D[Get Movie/TV Data]
|
||||
D --> E[Get Seasons/Episodes]
|
||||
E --> F[Display Smart Names]
|
||||
```
|
||||
|
||||
### 3. Воспроизведение
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Select File] --> B[Stream from Torrent]
|
||||
B --> C[Render to Video Element]
|
||||
C --> D[Show Progress]
|
||||
```
|
||||
|
||||
## 📊 Структура ответа метаданных
|
||||
|
||||
### Для фильмов
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 155,
|
||||
"title": "Тёмный рыцарь",
|
||||
"type": "movie",
|
||||
"year": 2008,
|
||||
"posterPath": "https://image.tmdb.org/t/p/w500/...",
|
||||
"backdropPath": "https://image.tmdb.org/t/p/w500/...",
|
||||
"overview": "Описание фильма...",
|
||||
"runtime": 152,
|
||||
"genres": [
|
||||
{"id": 28, "name": "боевик"},
|
||||
{"id": 80, "name": "криминал"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Для сериалов
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 1396,
|
||||
"title": "Во все тяжкие",
|
||||
"type": "tv",
|
||||
"year": 2008,
|
||||
"posterPath": "https://image.tmdb.org/t/p/w500/...",
|
||||
"overview": "Описание сериала...",
|
||||
"seasons": [
|
||||
{
|
||||
"seasonNumber": 1,
|
||||
"name": "Сезон 1",
|
||||
"episodes": [
|
||||
{
|
||||
"episodeNumber": 1,
|
||||
"seasonNumber": 1,
|
||||
"name": "Пилот",
|
||||
"overview": "Описание серии...",
|
||||
"runtime": 58
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"episodes": [
|
||||
{
|
||||
"episodeNumber": 1,
|
||||
"seasonNumber": 1,
|
||||
"name": "Пилот",
|
||||
"overview": "Описание серии..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🛡️ Безопасность
|
||||
|
||||
### ⚠️ ВАЖНО: Клиентский подход
|
||||
- Торренты обрабатываются **ТОЛЬКО в браузере пользователя**
|
||||
- Сервер **НЕ ЗАГРУЖАЕТ** и **НЕ ХРАНИТ** торрент файлы
|
||||
- API используется только для получения метаданных из TMDB
|
||||
- Полное соответствие законодательству - сервер не участвует в торрент активности
|
||||
|
||||
### 🔒 Приватность
|
||||
- Никакая торрент активность не логируется на сервере
|
||||
- Магнет ссылки не сохраняются в базе данных
|
||||
- Пользовательские данные защищены стандартными методами API
|
||||
|
||||
## 🌟 Умные функции
|
||||
|
||||
### Автоматическое определение серий
|
||||
Плеер автоматически распознает:
|
||||
- **S01E01** - формат сезон/серия
|
||||
- **Breaking.Bad.S01E01** - название с сезоном
|
||||
- **Game.of.Thrones.1x01** - альтернативный формат
|
||||
|
||||
### Красивые названия
|
||||
Вместо:
|
||||
```
|
||||
Breaking.Bad.S01E01.720p.BluRay.x264-DEMAND.mkv
|
||||
```
|
||||
Показывает:
|
||||
```
|
||||
S1E1: Пилот
|
||||
```
|
||||
|
||||
### Информация о сериях
|
||||
- Название серии из TMDB
|
||||
- Описание эпизода
|
||||
- Продолжительность
|
||||
- Изображения (постеры)
|
||||
|
||||
## 🎯 Примеры интеграции
|
||||
|
||||
### React компонент
|
||||
```jsx
|
||||
function WebTorrentPlayer({ magnetLink }) {
|
||||
const openPlayer = () => {
|
||||
const url = `/api/v1/webtorrent/player?magnet=${encodeURIComponent(magnetLink)}`;
|
||||
window.open(url, '_blank', 'fullscreen=yes');
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={openPlayer} className="play-button">
|
||||
🎬 Смотреть в WebTorrent
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Получение метаданных перед открытием
|
||||
```javascript
|
||||
async function openWithMetadata(magnetLink, searchQuery) {
|
||||
try {
|
||||
// Получаем метаданные
|
||||
const metaResponse = await fetch(`/api/v1/webtorrent/metadata?query=${encodeURIComponent(searchQuery)}`);
|
||||
const metadata = await metaResponse.json();
|
||||
|
||||
if (metadata.success) {
|
||||
console.log('Найдено:', metadata.data.title, metadata.data.type);
|
||||
}
|
||||
|
||||
// Открываем плеер
|
||||
const playerUrl = `/api/v1/webtorrent/player?magnet=${encodeURIComponent(magnetLink)}`;
|
||||
window.open(playerUrl, '_blank');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 Технические детали
|
||||
|
||||
### Поддерживаемые форматы
|
||||
- **Видео**: MP4, AVI, MKV, MOV, WMV, FLV, WebM, M4V
|
||||
- **Кодеки**: H.264, H.265/HEVC, VP8, VP9
|
||||
- **Аудио**: AAC, MP3, AC3, DTS
|
||||
|
||||
### Требования браузера
|
||||
- Современные браузеры с поддержкой WebRTC
|
||||
- Chrome/Edge 45+, Firefox 42+, Safari 11+
|
||||
- Поддержка WebTorrent API
|
||||
|
||||
### Производительность
|
||||
- Потоковое воспроизведение с первых секунд
|
||||
- Умное кэширование наиболее просматриваемых частей
|
||||
- Адаптивная буферизация в зависимости от скорости
|
||||
|
||||
## 🚦 Статусы ответов
|
||||
|
||||
| Код | Описание |
|
||||
|-----|----------|
|
||||
| 200 | Успешно - плеер загружен или метаданные найдены |
|
||||
| 400 | Отсутствует magnet ссылка или query параметр |
|
||||
| 404 | Метаданные не найдены в TMDB |
|
||||
| 500 | Внутренняя ошибка сервера |
|
||||
|
||||
---
|
||||
|
||||
**🎬 NeoMovies WebTorrent Player** - современное решение для просмотра торрентов с соблюдением всех требований безопасности и законности! 🚀
|
||||
19
api/index.go
19
api/index.go
@@ -55,16 +55,19 @@ func Handler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
movieService := services.NewMovieService(globalDB, tmdbService)
|
||||
tvService := services.NewTVService(globalDB, tmdbService)
|
||||
favoritesService := services.NewFavoritesService(globalDB, tmdbService)
|
||||
torrentService := services.NewTorrentServiceWithConfig(globalCfg.RedAPIBaseURL, globalCfg.RedAPIKey)
|
||||
reactionsService := services.NewReactionsService(globalDB)
|
||||
|
||||
authHandler := handlersPkg.NewAuthHandler(authService)
|
||||
movieHandler := handlersPkg.NewMovieHandler(movieService)
|
||||
tvHandler := handlersPkg.NewTVHandler(tvService)
|
||||
favoritesHandler := handlersPkg.NewFavoritesHandler(favoritesService)
|
||||
docsHandler := handlersPkg.NewDocsHandler()
|
||||
searchHandler := handlersPkg.NewSearchHandler(tmdbService)
|
||||
categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService)
|
||||
playersHandler := handlersPkg.NewPlayersHandler(globalCfg)
|
||||
webtorrentHandler := handlersPkg.NewWebTorrentHandler(tmdbService)
|
||||
torrentsHandler := handlersPkg.NewTorrentsHandler(torrentService, tmdbService)
|
||||
reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService)
|
||||
imagesHandler := handlersPkg.NewImagesHandler()
|
||||
@@ -84,15 +87,19 @@ func Handler(w http.ResponseWriter, r *http.Request) {
|
||||
api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET")
|
||||
api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET")
|
||||
|
||||
router.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
|
||||
api.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
|
||||
|
||||
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
|
||||
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET")
|
||||
api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).Methods("GET")
|
||||
|
||||
api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/vibix/{imdb_id}", playersHandler.GetVibixPlayer).Methods("GET")
|
||||
|
||||
api.HandleFunc("/webtorrent/player", webtorrentHandler.OpenPlayer).Methods("GET")
|
||||
api.HandleFunc("/webtorrent/metadata", webtorrentHandler.GetMetadata).Methods("GET")
|
||||
|
||||
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
|
||||
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
|
||||
api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET")
|
||||
@@ -127,9 +134,10 @@ func Handler(w http.ResponseWriter, r *http.Request) {
|
||||
protected := api.PathPrefix("").Subrouter()
|
||||
protected.Use(middleware.JWTAuth(globalCfg.JWTSecret))
|
||||
|
||||
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET")
|
||||
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST")
|
||||
protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE")
|
||||
protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET")
|
||||
protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST")
|
||||
protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE")
|
||||
protected.HandleFunc("/favorites/{id}/check", favoritesHandler.CheckIsFavorite).Methods("GET")
|
||||
|
||||
protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET")
|
||||
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
|
||||
@@ -143,8 +151,9 @@ func Handler(w http.ResponseWriter, r *http.Request) {
|
||||
corsHandler := handlers.CORS(
|
||||
handlers.AllowedOrigins([]string{"*"}),
|
||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
|
||||
handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}),
|
||||
handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With", "X-CSRF-Token"}),
|
||||
handlers.AllowCredentials(),
|
||||
handlers.ExposedHeaders([]string{"Authorization", "Content-Type"}),
|
||||
)
|
||||
|
||||
corsHandler(router).ServeHTTP(w, r)
|
||||
|
||||
19
main.go
19
main.go
@@ -35,16 +35,19 @@ func main() {
|
||||
|
||||
movieService := services.NewMovieService(db, tmdbService)
|
||||
tvService := services.NewTVService(db, tmdbService)
|
||||
favoritesService := services.NewFavoritesService(db, tmdbService)
|
||||
torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey)
|
||||
reactionsService := services.NewReactionsService(db)
|
||||
|
||||
authHandler := appHandlers.NewAuthHandler(authService)
|
||||
movieHandler := appHandlers.NewMovieHandler(movieService)
|
||||
tvHandler := appHandlers.NewTVHandler(tvService)
|
||||
favoritesHandler := appHandlers.NewFavoritesHandler(favoritesService)
|
||||
docsHandler := appHandlers.NewDocsHandler()
|
||||
searchHandler := appHandlers.NewSearchHandler(tmdbService)
|
||||
categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService)
|
||||
playersHandler := appHandlers.NewPlayersHandler(cfg)
|
||||
webtorrentHandler := appHandlers.NewWebTorrentHandler(tmdbService)
|
||||
torrentsHandler := appHandlers.NewTorrentsHandler(torrentService, tmdbService)
|
||||
reactionsHandler := appHandlers.NewReactionsHandler(reactionsService)
|
||||
imagesHandler := appHandlers.NewImagesHandler()
|
||||
@@ -64,15 +67,19 @@ func main() {
|
||||
api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET")
|
||||
api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).Methods("GET")
|
||||
|
||||
r.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
|
||||
api.HandleFunc("/search/multi", searchHandler.MultiSearch).Methods("GET")
|
||||
|
||||
api.HandleFunc("/categories", categoriesHandler.GetCategories).Methods("GET")
|
||||
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).Methods("GET")
|
||||
api.HandleFunc("/categories/{id}/media", categoriesHandler.GetMediaByCategory).Methods("GET")
|
||||
|
||||
api.HandleFunc("/players/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).Methods("GET")
|
||||
api.HandleFunc("/players/vibix/{imdb_id}", playersHandler.GetVibixPlayer).Methods("GET")
|
||||
|
||||
api.HandleFunc("/webtorrent/player", webtorrentHandler.OpenPlayer).Methods("GET")
|
||||
api.HandleFunc("/webtorrent/metadata", webtorrentHandler.GetMetadata).Methods("GET")
|
||||
|
||||
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
|
||||
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
|
||||
api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET")
|
||||
@@ -107,9 +114,10 @@ func main() {
|
||||
protected := api.PathPrefix("").Subrouter()
|
||||
protected.Use(middleware.JWTAuth(cfg.JWTSecret))
|
||||
|
||||
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET")
|
||||
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST")
|
||||
protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE")
|
||||
protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET")
|
||||
protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST")
|
||||
protected.HandleFunc("/favorites/{id}", favoritesHandler.RemoveFromFavorites).Methods("DELETE")
|
||||
protected.HandleFunc("/favorites/{id}/check", favoritesHandler.CheckIsFavorite).Methods("GET")
|
||||
|
||||
protected.HandleFunc("/auth/profile", authHandler.GetProfile).Methods("GET")
|
||||
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
|
||||
@@ -123,8 +131,9 @@ func main() {
|
||||
corsHandler := handlers.CORS(
|
||||
handlers.AllowedOrigins([]string{"*"}),
|
||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
|
||||
handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With"}),
|
||||
handlers.AllowedHeaders([]string{"Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With", "X-CSRF-Token"}),
|
||||
handlers.AllowCredentials(),
|
||||
handlers.ExposedHeaders([]string{"Authorization", "Content-Type"}),
|
||||
)
|
||||
|
||||
var finalHandler http.Handler
|
||||
|
||||
BIN
neomovies-api
Executable file
BIN
neomovies-api
Executable file
Binary file not shown.
@@ -53,7 +53,7 @@ func (h *CategoriesHandler) GetCategories(w http.ResponseWriter, r *http.Request
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *CategoriesHandler) GetMediaByCategory(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
categoryID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
@@ -67,20 +67,46 @@ func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.R
|
||||
language = "ru-RU"
|
||||
}
|
||||
|
||||
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 для получения фильмов по жанру
|
||||
movies, err := h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
data, err2 = h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language)
|
||||
} else {
|
||||
// Используем discover API для получения сериалов по жанру
|
||||
data, err2 = h.tmdbService.DiscoverTVByGenre(categoryID, page, language)
|
||||
}
|
||||
|
||||
if err2 != nil {
|
||||
http.Error(w, err2.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
Data: data,
|
||||
Message: "Media retrieved successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// Старый метод для обратной совместимости
|
||||
func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.Request) {
|
||||
// Просто перенаправляем на новый метод
|
||||
h.GetMediaByCategory(w, r)
|
||||
}
|
||||
|
||||
func generateSlug(name string) string {
|
||||
// Простая функция для создания slug из названия
|
||||
// В реальном проекте стоит использовать более сложную логику
|
||||
|
||||
@@ -37,6 +37,8 @@ func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With")
|
||||
json.NewEncoder(w).Encode(spec)
|
||||
}
|
||||
|
||||
@@ -64,6 +66,8 @@ func (h *DocsHandler) ServeDocs(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
fmt.Fprintln(w, htmlContent)
|
||||
}
|
||||
|
||||
@@ -141,7 +145,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/search/multi": map[string]interface{}{
|
||||
"/api/v1/search/multi": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"summary": "Мультипоиск",
|
||||
"description": "Поиск фильмов, сериалов и актеров",
|
||||
@@ -209,6 +213,58 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/v1/categories/{id}/media": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"summary": "Медиа по категории",
|
||||
"description": "Получение фильмов или сериалов по категории",
|
||||
"tags": []string{"Categories"},
|
||||
"parameters": []map[string]interface{}{
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": map[string]string{"type": "integer"},
|
||||
"description": "ID категории",
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"movie", "tv"},
|
||||
"default": "movie",
|
||||
},
|
||||
"description": "Тип медиа: movie или tv",
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
},
|
||||
"description": "Номер страницы",
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"default": "ru-RU",
|
||||
},
|
||||
"description": "Язык ответа",
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Медиа по категории",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/v1/players/alloha/{imdb_id}": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"summary": "Плеер Alloha",
|
||||
@@ -277,6 +333,76 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/v1/webtorrent/player": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"summary": "WebTorrent плеер",
|
||||
"description": "Открытие WebTorrent плеера с магнет ссылкой. Плеер работает полностью на стороне клиента.",
|
||||
"tags": []string{"WebTorrent"},
|
||||
"parameters": []map[string]interface{}{
|
||||
{
|
||||
"name": "magnet",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": map[string]string{"type": "string"},
|
||||
"description": "Магнет ссылка торрента",
|
||||
},
|
||||
{
|
||||
"name": "X-Magnet-Link",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": map[string]string{"type": "string"},
|
||||
"description": "Магнет ссылка через заголовок (альтернативный способ)",
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "HTML страница с WebTorrent плеером",
|
||||
"content": map[string]interface{}{
|
||||
"text/html": map[string]interface{}{
|
||||
"schema": map[string]string{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": map[string]interface{}{
|
||||
"description": "Отсутствует магнет ссылка",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/v1/webtorrent/metadata": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"summary": "Метаданные медиа",
|
||||
"description": "Получение метаданных фильма или сериала по названию для WebTorrent плеера",
|
||||
"tags": []string{"WebTorrent"},
|
||||
"parameters": []map[string]interface{}{
|
||||
{
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": map[string]string{"type": "string"},
|
||||
"description": "Название для поиска (извлеченное из торрента)",
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Метаданные найдены",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/WebTorrentMetadata",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": map[string]interface{}{
|
||||
"description": "Отсутствует параметр query",
|
||||
},
|
||||
"404": map[string]interface{}{
|
||||
"description": "Метаданные не найдены",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/v1/torrents/search/{imdbId}": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"summary": "Поиск торрентов",
|
||||
@@ -715,15 +841,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
|
||||
},
|
||||
"/api/v1/favorites": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"summary": "Получить избранные фильмы",
|
||||
"description": "Список избранных фильмов пользователя",
|
||||
"summary": "Получить избранное",
|
||||
"description": "Список избранных фильмов и сериалов пользователя",
|
||||
"tags": []string{"Favorites"},
|
||||
"security": []map[string][]string{
|
||||
{"bearerAuth": []string{}},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Список избранных фильмов",
|
||||
"description": "Список избранного",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -731,7 +857,7 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
|
||||
"/api/v1/favorites/{id}": map[string]interface{}{
|
||||
"post": map[string]interface{}{
|
||||
"summary": "Добавить в избранное",
|
||||
"description": "Добавление фильма в избранное",
|
||||
"description": "Добавление фильма или сериала в избранное",
|
||||
"tags": []string{"Favorites"},
|
||||
"security": []map[string][]string{
|
||||
{"bearerAuth": []string{}},
|
||||
@@ -742,18 +868,29 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": map[string]string{"type": "string"},
|
||||
"description": "ID фильма",
|
||||
"description": "ID медиа",
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"movie", "tv"},
|
||||
"default": "movie",
|
||||
},
|
||||
"description": "Тип медиа: movie или tv",
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Фильм добавлен в избранное",
|
||||
"description": "Добавлено в избранное",
|
||||
},
|
||||
},
|
||||
},
|
||||
"delete": map[string]interface{}{
|
||||
"summary": "Удалить из избранного",
|
||||
"description": "Удаление фильма из избранного",
|
||||
"description": "Удаление фильма или сериала из избранного",
|
||||
"tags": []string{"Favorites"},
|
||||
"security": []map[string][]string{
|
||||
{"bearerAuth": []string{}},
|
||||
@@ -764,12 +901,58 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": map[string]string{"type": "string"},
|
||||
"description": "ID фильма",
|
||||
"description": "ID медиа",
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"movie", "tv"},
|
||||
"default": "movie",
|
||||
},
|
||||
"description": "Тип медиа: movie или tv",
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Фильм удален из избранного",
|
||||
"description": "Удалено из избранного",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/v1/favorites/{id}/check": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"summary": "Проверить избранное",
|
||||
"description": "Проверка, находится ли медиа в избранном",
|
||||
"tags": []string{"Favorites"},
|
||||
"security": []map[string][]string{
|
||||
{"bearerAuth": []string{}},
|
||||
},
|
||||
"parameters": []map[string]interface{}{
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": map[string]string{"type": "string"},
|
||||
"description": "ID медиа",
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"movie", "tv"},
|
||||
"default": "movie",
|
||||
},
|
||||
"description": "Тип медиа: movie или tv",
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "Статус избранного",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1425,6 +1608,71 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
|
||||
"twitter_id": map[string]string{"type": "string"},
|
||||
},
|
||||
},
|
||||
"WebTorrentMetadata": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]string{"type": "integer"},
|
||||
"title": map[string]string{"type": "string"},
|
||||
"type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"movie", "tv"},
|
||||
},
|
||||
"year": map[string]string{"type": "integer"},
|
||||
"posterPath": map[string]string{"type": "string"},
|
||||
"backdropPath": map[string]string{"type": "string"},
|
||||
"overview": map[string]string{"type": "string"},
|
||||
"runtime": map[string]string{"type": "integer"},
|
||||
"genres": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/Genre",
|
||||
},
|
||||
},
|
||||
"seasons": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/SeasonMetadata",
|
||||
},
|
||||
},
|
||||
"episodes": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/EpisodeMetadata",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"SeasonMetadata": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"seasonNumber": map[string]string{"type": "integer"},
|
||||
"name": map[string]string{"type": "string"},
|
||||
"episodes": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/EpisodeMetadata",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"EpisodeMetadata": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"episodeNumber": map[string]string{"type": "integer"},
|
||||
"seasonNumber": map[string]string{"type": "integer"},
|
||||
"name": map[string]string{"type": "string"},
|
||||
"overview": map[string]string{"type": "string"},
|
||||
"runtime": map[string]string{"type": "integer"},
|
||||
"stillPath": map[string]string{"type": "string"},
|
||||
},
|
||||
},
|
||||
"Genre": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]string{"type": "integer"},
|
||||
"name": map[string]string{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
157
pkg/handlers/favorites.go
Normal file
157
pkg/handlers/favorites.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type FavoritesHandler struct {
|
||||
favoritesService *services.FavoritesService
|
||||
}
|
||||
|
||||
func NewFavoritesHandler(favoritesService *services.FavoritesService) *FavoritesHandler {
|
||||
return &FavoritesHandler{
|
||||
favoritesService: favoritesService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *FavoritesHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
favorites, err := h.favoritesService.GetFavorites(userID)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to get favorites: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: favorites,
|
||||
Message: "Favorites retrieved successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FavoritesHandler) AddToFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
mediaID := vars["id"]
|
||||
mediaType := r.URL.Query().Get("type")
|
||||
|
||||
if mediaID == "" {
|
||||
http.Error(w, "Media ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if mediaType == "" {
|
||||
mediaType = "movie" // По умолчанию фильм для обратной совместимости
|
||||
}
|
||||
|
||||
if mediaType != "movie" && mediaType != "tv" {
|
||||
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.favoritesService.AddToFavorites(userID, mediaID, mediaType)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to add to favorites: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Message: "Added to favorites successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FavoritesHandler) RemoveFromFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
mediaID := vars["id"]
|
||||
mediaType := r.URL.Query().Get("type")
|
||||
|
||||
if mediaID == "" {
|
||||
http.Error(w, "Media ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if mediaType == "" {
|
||||
mediaType = "movie" // По умолчанию фильм для обратной совместимости
|
||||
}
|
||||
|
||||
if mediaType != "movie" && mediaType != "tv" {
|
||||
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.favoritesService.RemoveFromFavorites(userID, mediaID, mediaType)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to remove from favorites: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Message: "Removed from favorites successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FavoritesHandler) CheckIsFavorite(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
mediaID := vars["id"]
|
||||
mediaType := r.URL.Query().Get("type")
|
||||
|
||||
if mediaID == "" {
|
||||
http.Error(w, "Media ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if mediaType == "" {
|
||||
mediaType = "movie" // По умолчанию фильм для обратной совместимости
|
||||
}
|
||||
|
||||
if mediaType != "movie" && mediaType != "tv" {
|
||||
http.Error(w, "Media type must be 'movie' or 'tv'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
isFavorite, err := h.favoritesService.IsFavorite(userID, mediaID, mediaType)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to check favorite status: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]bool{"isFavorite": isFavorite},
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"neomovies-api/pkg/middleware"
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
@@ -190,73 +189,7 @@ func (h *MovieHandler) GetSimilar(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) GetFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
language := r.URL.Query().Get("language")
|
||||
|
||||
movies, err := h.movieService.GetFavorites(userID, language)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: movies,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) AddToFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
movieID := vars["id"]
|
||||
|
||||
err := h.movieService.AddToFavorites(userID, movieID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Message: "Movie added to favorites",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) RemoveFromFavorites(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
movieID := vars["id"]
|
||||
|
||||
err := h.movieService.RemoveFromFavorites(userID, movieID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Message: "Movie removed from favorites",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MovieHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
578
pkg/handlers/webtorrent.go
Normal file
578
pkg/handlers/webtorrent.go
Normal file
@@ -0,0 +1,578 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
"neomovies-api/pkg/services"
|
||||
)
|
||||
|
||||
type WebTorrentHandler struct {
|
||||
tmdbService *services.TMDBService
|
||||
}
|
||||
|
||||
func NewWebTorrentHandler(tmdbService *services.TMDBService) *WebTorrentHandler {
|
||||
return &WebTorrentHandler{
|
||||
tmdbService: tmdbService,
|
||||
}
|
||||
}
|
||||
|
||||
// Структура для ответа с метаданными
|
||||
type MediaMetadata struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"` // "movie" or "tv"
|
||||
Year int `json:"year,omitempty"`
|
||||
PosterPath string `json:"posterPath,omitempty"`
|
||||
BackdropPath string `json:"backdropPath,omitempty"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Seasons []SeasonMetadata `json:"seasons,omitempty"`
|
||||
Episodes []EpisodeMetadata `json:"episodes,omitempty"`
|
||||
Runtime int `json:"runtime,omitempty"`
|
||||
Genres []models.Genre `json:"genres,omitempty"`
|
||||
}
|
||||
|
||||
type SeasonMetadata struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Name string `json:"name"`
|
||||
Episodes []EpisodeMetadata `json:"episodes"`
|
||||
}
|
||||
|
||||
type EpisodeMetadata struct {
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
Runtime int `json:"runtime,omitempty"`
|
||||
StillPath string `json:"stillPath,omitempty"`
|
||||
}
|
||||
|
||||
// Открытие плеера с магнет ссылкой
|
||||
func (h *WebTorrentHandler) OpenPlayer(w http.ResponseWriter, r *http.Request) {
|
||||
magnetLink := r.Header.Get("X-Magnet-Link")
|
||||
if magnetLink == "" {
|
||||
magnetLink = r.URL.Query().Get("magnet")
|
||||
}
|
||||
|
||||
if magnetLink == "" {
|
||||
http.Error(w, "Magnet link is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Декодируем magnet ссылку если она закодирована
|
||||
decodedMagnet, err := url.QueryUnescape(magnetLink)
|
||||
if err != nil {
|
||||
decodedMagnet = magnetLink
|
||||
}
|
||||
|
||||
// Отдаем HTML страницу с плеером
|
||||
tmpl := `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NeoMovies WebTorrent Player</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/webtorrent@latest/webtorrent.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-container {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 4px solid #333;
|
||||
border-top: 4px solid #fff;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.media-info {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 50;
|
||||
background: rgba(0,0,0,0.8);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
max-width: 400px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.media-overview {
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
z-index: 50;
|
||||
background: rgba(0,0,0,0.8);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
background: #333;
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.file-item.active {
|
||||
background: #007bff;
|
||||
}
|
||||
|
||||
.episode-info {
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff4444;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="player-container">
|
||||
<div class="loading" id="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>Загружаем торрент...</div>
|
||||
<div id="loadingProgress" style="margin-top: 10px; font-size: 12px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="media-info" id="mediaInfo">
|
||||
<div class="media-title" id="mediaTitle"></div>
|
||||
<div class="media-overview" id="mediaOverview"></div>
|
||||
</div>
|
||||
|
||||
<div class="controls" id="controls">
|
||||
<div class="episode-info" id="episodeInfo"></div>
|
||||
<div class="file-list" id="fileList"></div>
|
||||
</div>
|
||||
|
||||
<video id="videoPlayer" controls style="display: none;"></video>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const magnetLink = {{.MagnetLink}};
|
||||
const client = new WebTorrent();
|
||||
|
||||
let currentTorrent = null;
|
||||
let mediaMetadata = null;
|
||||
|
||||
const elements = {
|
||||
loading: document.getElementById('loading'),
|
||||
mediaInfo: document.getElementById('mediaInfo'),
|
||||
mediaTitle: document.getElementById('mediaTitle'),
|
||||
mediaOverview: document.getElementById('mediaOverview'),
|
||||
controls: document.getElementById('controls'),
|
||||
episodeInfo: document.getElementById('episodeInfo'),
|
||||
fileList: document.getElementById('fileList'),
|
||||
videoPlayer: document.getElementById('videoPlayer'),
|
||||
loadingProgress: document.getElementById('loadingProgress')
|
||||
};
|
||||
|
||||
// Загружаем торрент
|
||||
client.add(magnetLink, onTorrent);
|
||||
|
||||
function onTorrent(torrent) {
|
||||
currentTorrent = torrent;
|
||||
console.log('Торрент загружен:', torrent.name);
|
||||
|
||||
// Получаем метаданные через API
|
||||
fetchMediaMetadata(torrent.name);
|
||||
|
||||
// Фильтруем только видео файлы
|
||||
const videoFiles = torrent.files.filter(file =>
|
||||
/\.(mp4|avi|mkv|mov|wmv|flv|webm|m4v)$/i.test(file.name)
|
||||
);
|
||||
|
||||
if (videoFiles.length === 0) {
|
||||
showError('Видео файлы не найдены в торренте');
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем список файлов
|
||||
renderFileList(videoFiles);
|
||||
|
||||
// Автоматически выбираем первый файл
|
||||
if (videoFiles.length > 0) {
|
||||
playFile(videoFiles[0], 0);
|
||||
}
|
||||
|
||||
elements.loading.style.display = 'none';
|
||||
elements.controls.style.display = 'block';
|
||||
}
|
||||
|
||||
function fetchMediaMetadata(torrentName) {
|
||||
// Извлекаем название для поиска из имени торрента
|
||||
const searchQuery = extractTitleFromTorrentName(torrentName);
|
||||
|
||||
fetch('/api/v1/webtorrent/metadata?query=' + encodeURIComponent(searchQuery))
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.data) {
|
||||
mediaMetadata = data.data;
|
||||
displayMediaInfo(mediaMetadata);
|
||||
}
|
||||
})
|
||||
.catch(error => console.log('Метаданные не найдены:', error));
|
||||
}
|
||||
|
||||
function extractTitleFromTorrentName(name) {
|
||||
// Убираем расширения файлов, качество, кодеки и т.д.
|
||||
let title = name
|
||||
.replace(/\.(mp4|avi|mkv|mov|wmv|flv|webm|m4v)$/i, '')
|
||||
.replace(/\b(1080p|720p|480p|4K|BluRay|WEBRip|DVDRip|HDTV|x264|x265|HEVC|DTS|AC3)\b/gi, '')
|
||||
.replace(/\b(S\d{1,2}E\d{1,2}|\d{4})\b/g, '')
|
||||
.replace(/[\.\-_\[\]()]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
function displayMediaInfo(metadata) {
|
||||
elements.mediaTitle.textContent = metadata.title + (metadata.year ? ' (' + metadata.year + ')' : '');
|
||||
elements.mediaOverview.textContent = metadata.overview || '';
|
||||
elements.mediaInfo.style.display = 'block';
|
||||
}
|
||||
|
||||
function renderFileList(files) {
|
||||
elements.fileList.innerHTML = '';
|
||||
|
||||
files.forEach((file, index) => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'file-item';
|
||||
button.textContent = getDisplayName(file.name, index);
|
||||
button.onclick = () => playFile(file, index);
|
||||
elements.fileList.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function getDisplayName(fileName, index) {
|
||||
if (!mediaMetadata) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
// Для сериалов пытаемся определить сезон и серию
|
||||
if (mediaMetadata.type === 'tv') {
|
||||
const episodeMatch = fileName.match(/S(\d{1,2})E(\d{1,2})/i);
|
||||
if (episodeMatch) {
|
||||
const season = parseInt(episodeMatch[1]);
|
||||
const episode = parseInt(episodeMatch[2]);
|
||||
|
||||
const episodeData = mediaMetadata.episodes?.find(ep =>
|
||||
ep.seasonNumber === season && ep.episodeNumber === episode
|
||||
);
|
||||
|
||||
if (episodeData) {
|
||||
return 'S' + season + 'E' + episode + ': ' + episodeData.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mediaMetadata.title + ' - Файл ' + (index + 1);
|
||||
}
|
||||
|
||||
function playFile(file, index) {
|
||||
// Убираем активный класс со всех кнопок
|
||||
document.querySelectorAll('.file-item').forEach(btn => btn.classList.remove('active'));
|
||||
// Добавляем активный класс к выбранной кнопке
|
||||
document.querySelectorAll('.file-item')[index].classList.add('active');
|
||||
|
||||
// Обновляем информацию о серии
|
||||
updateEpisodeInfo(file.name, index);
|
||||
|
||||
// Воспроизводим файл
|
||||
file.renderTo(elements.videoPlayer, (err) => {
|
||||
if (err) {
|
||||
showError('Ошибка воспроизведения: ' + err.message);
|
||||
} else {
|
||||
elements.videoPlayer.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateEpisodeInfo(fileName, index) {
|
||||
if (!mediaMetadata) {
|
||||
elements.episodeInfo.textContent = 'Файл: ' + fileName;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaMetadata.type === 'tv') {
|
||||
const episodeMatch = fileName.match(/S(\d{1,2})E(\d{1,2})/i);
|
||||
if (episodeMatch) {
|
||||
const season = parseInt(episodeMatch[1]);
|
||||
const episode = parseInt(episodeMatch[2]);
|
||||
|
||||
const episodeData = mediaMetadata.episodes?.find(ep =>
|
||||
ep.seasonNumber === season && ep.episodeNumber === episode
|
||||
);
|
||||
|
||||
if (episodeData) {
|
||||
elements.episodeInfo.innerHTML =
|
||||
'<strong>Сезон ' + season + ', Серия ' + episode + '</strong><br>' +
|
||||
episodeData.name +
|
||||
(episodeData.overview ? '<br><span style="color: #999; font-size: 12px;">' + episodeData.overview + '</span>' : '');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
elements.episodeInfo.textContent = mediaMetadata.title + ' - Часть ' + (index + 1);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
elements.loading.innerHTML = '<div class="error">' + message + '</div>';
|
||||
}
|
||||
|
||||
// Обработка прогресса загрузки
|
||||
client.on('torrent', (torrent) => {
|
||||
torrent.on('download', () => {
|
||||
const progress = Math.round(torrent.progress * 100);
|
||||
const downloadSpeed = (torrent.downloadSpeed / 1024 / 1024).toFixed(1);
|
||||
elements.loadingProgress.textContent =
|
||||
'Прогресс: ' + progress + '% | Скорость: ' + downloadSpeed + ' MB/s';
|
||||
});
|
||||
});
|
||||
|
||||
// Глобальная обработка ошибок
|
||||
client.on('error', (err) => {
|
||||
showError('Ошибка торрент клиента: ' + err.message);
|
||||
});
|
||||
|
||||
// Управление с клавиатуры
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
if (elements.videoPlayer.paused) {
|
||||
elements.videoPlayer.play();
|
||||
} else {
|
||||
elements.videoPlayer.pause();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Создаем template и выполняем его
|
||||
t, err := template.New("player").Parse(tmpl)
|
||||
if err != nil {
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
MagnetLink string
|
||||
}{
|
||||
MagnetLink: strconv.Quote(decodedMagnet),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
err = t.Execute(w, data)
|
||||
if err != nil {
|
||||
http.Error(w, "Template execution error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// API для получения метаданных фильма/сериала по названию
|
||||
func (h *WebTorrentHandler) GetMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("query")
|
||||
if query == "" {
|
||||
http.Error(w, "Query parameter is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Пытаемся определить тип контента и найти его
|
||||
metadata, err := h.searchAndBuildMetadata(query)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: false,
|
||||
Message: "Media not found: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(models.APIResponse{
|
||||
Success: true,
|
||||
Data: metadata,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WebTorrentHandler) searchAndBuildMetadata(query string) (*MediaMetadata, error) {
|
||||
// Сначала пробуем поиск по фильмам
|
||||
movieResults, err := h.tmdbService.SearchMovies(query, 1, "ru-RU", "", 0)
|
||||
if err == nil && len(movieResults.Results) > 0 {
|
||||
movie := movieResults.Results[0]
|
||||
|
||||
// Получаем детальную информацию о фильме
|
||||
fullMovie, err := h.tmdbService.GetMovie(movie.ID, "ru-RU")
|
||||
if err == nil {
|
||||
return &MediaMetadata{
|
||||
ID: fullMovie.ID,
|
||||
Title: fullMovie.Title,
|
||||
Type: "movie",
|
||||
Year: extractYear(fullMovie.ReleaseDate),
|
||||
PosterPath: fullMovie.PosterPath,
|
||||
BackdropPath: fullMovie.BackdropPath,
|
||||
Overview: fullMovie.Overview,
|
||||
Runtime: fullMovie.Runtime,
|
||||
Genres: fullMovie.Genres,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Затем пробуем поиск по сериалам
|
||||
tvResults, err := h.tmdbService.SearchTV(query, 1, "ru-RU", 0)
|
||||
if err == nil && len(tvResults.Results) > 0 {
|
||||
tv := tvResults.Results[0]
|
||||
|
||||
// Получаем детальную информацию о сериале
|
||||
fullTV, err := h.tmdbService.GetTVShow(tv.ID, "ru-RU")
|
||||
if err == nil {
|
||||
metadata := &MediaMetadata{
|
||||
ID: fullTV.ID,
|
||||
Title: fullTV.Name,
|
||||
Type: "tv",
|
||||
Year: extractYear(fullTV.FirstAirDate),
|
||||
PosterPath: fullTV.PosterPath,
|
||||
BackdropPath: fullTV.BackdropPath,
|
||||
Overview: fullTV.Overview,
|
||||
Genres: fullTV.Genres,
|
||||
}
|
||||
|
||||
// Получаем информацию о сезонах и сериях
|
||||
var allEpisodes []EpisodeMetadata
|
||||
for _, season := range fullTV.Seasons {
|
||||
if season.SeasonNumber == 0 {
|
||||
continue // Пропускаем спецвыпуски
|
||||
}
|
||||
|
||||
seasonDetails, err := h.tmdbService.GetTVSeason(fullTV.ID, season.SeasonNumber, "ru-RU")
|
||||
if err == nil {
|
||||
var episodes []EpisodeMetadata
|
||||
for _, episode := range seasonDetails.Episodes {
|
||||
episodeData := EpisodeMetadata{
|
||||
EpisodeNumber: episode.EpisodeNumber,
|
||||
SeasonNumber: season.SeasonNumber,
|
||||
Name: episode.Name,
|
||||
Overview: episode.Overview,
|
||||
Runtime: episode.Runtime,
|
||||
StillPath: episode.StillPath,
|
||||
}
|
||||
episodes = append(episodes, episodeData)
|
||||
allEpisodes = append(allEpisodes, episodeData)
|
||||
}
|
||||
|
||||
metadata.Seasons = append(metadata.Seasons, SeasonMetadata{
|
||||
SeasonNumber: season.SeasonNumber,
|
||||
Name: season.Name,
|
||||
Episodes: episodes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
metadata.Episodes = allEpisodes
|
||||
return metadata, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func extractYear(dateString string) int {
|
||||
if len(dateString) >= 4 {
|
||||
yearStr := dateString[:4]
|
||||
if year, err := strconv.Atoi(yearStr); err == nil {
|
||||
return year
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Проверяем есть ли нужные методы в TMDB сервисе
|
||||
func (h *WebTorrentHandler) checkMethods() {
|
||||
// Эти методы должны существовать в TMDBService:
|
||||
// - SearchMovies
|
||||
// - SearchTV
|
||||
// - GetMovie
|
||||
// - GetTVShow
|
||||
// - GetTVSeason
|
||||
}
|
||||
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"`
|
||||
}
|
||||
@@ -159,6 +159,31 @@ type Season struct {
|
||||
SeasonNumber int `json:"season_number"`
|
||||
}
|
||||
|
||||
type SeasonDetails struct {
|
||||
AirDate string `json:"air_date"`
|
||||
Episodes []Episode `json:"episodes"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
ID int `json:"id"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
SeasonNumber int `json:"season_number"`
|
||||
}
|
||||
|
||||
type Episode struct {
|
||||
AirDate string `json:"air_date"`
|
||||
EpisodeNumber int `json:"episode_number"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Overview string `json:"overview"`
|
||||
ProductionCode string `json:"production_code"`
|
||||
Runtime int `json:"runtime"`
|
||||
SeasonNumber int `json:"season_number"`
|
||||
ShowID int `json:"show_id"`
|
||||
StillPath string `json:"still_path"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
type TMDBResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []Movie `json:"results"`
|
||||
|
||||
147
pkg/services/favorites.go
Normal file
147
pkg/services/favorites.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type FavoritesService struct {
|
||||
db *mongo.Database
|
||||
tmdb *TMDBService
|
||||
}
|
||||
|
||||
func NewFavoritesService(db *mongo.Database, tmdb *TMDBService) *FavoritesService {
|
||||
return &FavoritesService{
|
||||
db: db,
|
||||
tmdb: tmdb,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FavoritesService) AddToFavorites(userID, mediaID, mediaType string) error {
|
||||
collection := s.db.Collection("favorites")
|
||||
|
||||
// Проверяем, не добавлен ли уже в избранное
|
||||
filter := bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": mediaID,
|
||||
"mediaType": mediaType,
|
||||
}
|
||||
|
||||
var existingFavorite models.Favorite
|
||||
err := collection.FindOne(context.Background(), filter).Decode(&existingFavorite)
|
||||
if err == nil {
|
||||
// Уже в избранном
|
||||
return nil
|
||||
}
|
||||
|
||||
var title, posterPath string
|
||||
|
||||
// Получаем информацию из TMDB в зависимости от типа медиа
|
||||
mediaIDInt, err := strconv.Atoi(mediaID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid media ID: %s", mediaID)
|
||||
}
|
||||
|
||||
if mediaType == "movie" {
|
||||
movie, err := s.tmdb.GetMovie(mediaIDInt, "en-US")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title = movie.Title
|
||||
posterPath = movie.PosterPath
|
||||
} else if mediaType == "tv" {
|
||||
tv, err := s.tmdb.GetTVShow(mediaIDInt, "en-US")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
title = tv.Name
|
||||
posterPath = tv.PosterPath
|
||||
} else {
|
||||
return fmt.Errorf("invalid media type: %s", mediaType)
|
||||
}
|
||||
|
||||
// Формируем полный URL для постера
|
||||
if posterPath != "" {
|
||||
posterPath = fmt.Sprintf("https://image.tmdb.org/t/p/w500%s", posterPath)
|
||||
}
|
||||
|
||||
favorite := models.Favorite{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
MediaType: mediaType,
|
||||
Title: title,
|
||||
PosterPath: posterPath,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_, err = collection.InsertOne(context.Background(), favorite)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FavoritesService) RemoveFromFavorites(userID, mediaID, mediaType string) error {
|
||||
collection := s.db.Collection("favorites")
|
||||
|
||||
filter := bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": mediaID,
|
||||
"mediaType": mediaType,
|
||||
}
|
||||
|
||||
_, err := collection.DeleteOne(context.Background(), filter)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *FavoritesService) GetFavorites(userID string) ([]models.Favorite, error) {
|
||||
collection := s.db.Collection("favorites")
|
||||
|
||||
filter := bson.M{
|
||||
"userId": userID,
|
||||
}
|
||||
|
||||
cursor, err := collection.Find(context.Background(), filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cursor.Close(context.Background())
|
||||
|
||||
var favorites []models.Favorite
|
||||
err = cursor.All(context.Background(), &favorites)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Возвращаем пустой массив вместо nil если нет избранных
|
||||
if favorites == nil {
|
||||
favorites = []models.Favorite{}
|
||||
}
|
||||
|
||||
return favorites, nil
|
||||
}
|
||||
|
||||
func (s *FavoritesService) IsFavorite(userID, mediaID, mediaType string) (bool, error) {
|
||||
collection := s.db.Collection("favorites")
|
||||
|
||||
filter := bson.M{
|
||||
"userId": userID,
|
||||
"mediaId": mediaID,
|
||||
"mediaType": mediaType,
|
||||
}
|
||||
|
||||
var favorite models.Favorite
|
||||
err := collection.FindOne(context.Background(), filter).Decode(&favorite)
|
||||
if err != nil {
|
||||
if err == mongo.ErrNoDocuments {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,23 +1,17 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
|
||||
"neomovies-api/pkg/models"
|
||||
)
|
||||
|
||||
type MovieService struct {
|
||||
db *mongo.Database
|
||||
tmdb *TMDBService
|
||||
}
|
||||
|
||||
func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService {
|
||||
return &MovieService{
|
||||
db: db,
|
||||
tmdb: tmdb,
|
||||
}
|
||||
}
|
||||
@@ -54,56 +48,7 @@ func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBRe
|
||||
return s.tmdb.GetSimilarMovies(id, page, language)
|
||||
}
|
||||
|
||||
func (s *MovieService) AddToFavorites(userID string, movieID string) error {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
filter := bson.M{"_id": userID}
|
||||
update := bson.M{
|
||||
"$addToSet": bson.M{"favorites": movieID},
|
||||
}
|
||||
|
||||
_, err := collection.UpdateOne(context.Background(), filter, update)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MovieService) RemoveFromFavorites(userID string, movieID string) error {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
filter := bson.M{"_id": userID}
|
||||
update := bson.M{
|
||||
"$pull": bson.M{"favorites": movieID},
|
||||
}
|
||||
|
||||
_, err := collection.UpdateOne(context.Background(), filter, update)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MovieService) GetFavorites(userID string, language string) ([]models.Movie, error) {
|
||||
collection := s.db.Collection("users")
|
||||
|
||||
var user models.User
|
||||
err := collection.FindOne(context.Background(), bson.M{"_id": userID}).Decode(&user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var movies []models.Movie
|
||||
for _, movieIDStr := range user.Favorites {
|
||||
movieID, err := strconv.Atoi(movieIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
movie, err := s.tmdb.GetMovie(movieID, language)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
movies = append(movies, *movie)
|
||||
}
|
||||
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
|
||||
return s.tmdb.GetMovieExternalIDs(id)
|
||||
|
||||
@@ -119,6 +119,11 @@ func (s *TMDBService) SearchMulti(query string, page int, language string) (*mod
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// Алиас для совместимости с новым WebTorrent handler
|
||||
func (s *TMDBService) SearchTV(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
|
||||
return s.SearchTVShows(query, page, language, firstAirDateYear)
|
||||
}
|
||||
|
||||
func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
@@ -477,3 +482,34 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string)
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user