Add WebTorrent Player(Experimental)

This commit is contained in:
2025-08-14 11:34:31 +00:00
parent d347c6003a
commit d790eb7903
14 changed files with 1556 additions and 148 deletions

271
WEBTORRENT_PLAYER.md Normal file
View 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** - современное решение для просмотра торрентов с соблюдением всех требований безопасности и законности! 🚀

View File

@@ -55,16 +55,19 @@ func Handler(w http.ResponseWriter, r *http.Request) {
movieService := services.NewMovieService(globalDB, tmdbService) movieService := services.NewMovieService(globalDB, tmdbService)
tvService := services.NewTVService(globalDB, tmdbService) tvService := services.NewTVService(globalDB, tmdbService)
favoritesService := services.NewFavoritesService(globalDB, tmdbService)
torrentService := services.NewTorrentServiceWithConfig(globalCfg.RedAPIBaseURL, globalCfg.RedAPIKey) torrentService := services.NewTorrentServiceWithConfig(globalCfg.RedAPIBaseURL, globalCfg.RedAPIKey)
reactionsService := services.NewReactionsService(globalDB) reactionsService := services.NewReactionsService(globalDB)
authHandler := handlersPkg.NewAuthHandler(authService) authHandler := handlersPkg.NewAuthHandler(authService)
movieHandler := handlersPkg.NewMovieHandler(movieService) movieHandler := handlersPkg.NewMovieHandler(movieService)
tvHandler := handlersPkg.NewTVHandler(tvService) tvHandler := handlersPkg.NewTVHandler(tvService)
favoritesHandler := handlersPkg.NewFavoritesHandler(favoritesService)
docsHandler := handlersPkg.NewDocsHandler() docsHandler := handlersPkg.NewDocsHandler()
searchHandler := handlersPkg.NewSearchHandler(tmdbService) searchHandler := handlersPkg.NewSearchHandler(tmdbService)
categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService) categoriesHandler := handlersPkg.NewCategoriesHandler(tmdbService)
playersHandler := handlersPkg.NewPlayersHandler(globalCfg) playersHandler := handlersPkg.NewPlayersHandler(globalCfg)
webtorrentHandler := handlersPkg.NewWebTorrentHandler(tmdbService)
torrentsHandler := handlersPkg.NewTorrentsHandler(torrentService, tmdbService) torrentsHandler := handlersPkg.NewTorrentsHandler(torrentService, tmdbService)
reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService) reactionsHandler := handlersPkg.NewReactionsHandler(reactionsService)
imagesHandler := handlersPkg.NewImagesHandler() 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/login", authHandler.GoogleLogin).Methods("GET")
api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).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", categoriesHandler.GetCategories).Methods("GET")
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).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/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).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/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/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).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 := api.PathPrefix("").Subrouter()
protected.Use(middleware.JWTAuth(globalCfg.JWTSecret)) protected.Use(middleware.JWTAuth(globalCfg.JWTSecret))
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET") protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET")
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST") protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST")
protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE") 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.GetProfile).Methods("GET")
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
@@ -143,8 +151,9 @@ func Handler(w http.ResponseWriter, r *http.Request) {
corsHandler := handlers.CORS( corsHandler := handlers.CORS(
handlers.AllowedOrigins([]string{"*"}), handlers.AllowedOrigins([]string{"*"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), 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.AllowCredentials(),
handlers.ExposedHeaders([]string{"Authorization", "Content-Type"}),
) )
corsHandler(router).ServeHTTP(w, r) corsHandler(router).ServeHTTP(w, r)

19
main.go
View File

@@ -35,16 +35,19 @@ func main() {
movieService := services.NewMovieService(db, tmdbService) movieService := services.NewMovieService(db, tmdbService)
tvService := services.NewTVService(db, tmdbService) tvService := services.NewTVService(db, tmdbService)
favoritesService := services.NewFavoritesService(db, tmdbService)
torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey) torrentService := services.NewTorrentServiceWithConfig(cfg.RedAPIBaseURL, cfg.RedAPIKey)
reactionsService := services.NewReactionsService(db) reactionsService := services.NewReactionsService(db)
authHandler := appHandlers.NewAuthHandler(authService) authHandler := appHandlers.NewAuthHandler(authService)
movieHandler := appHandlers.NewMovieHandler(movieService) movieHandler := appHandlers.NewMovieHandler(movieService)
tvHandler := appHandlers.NewTVHandler(tvService) tvHandler := appHandlers.NewTVHandler(tvService)
favoritesHandler := appHandlers.NewFavoritesHandler(favoritesService)
docsHandler := appHandlers.NewDocsHandler() docsHandler := appHandlers.NewDocsHandler()
searchHandler := appHandlers.NewSearchHandler(tmdbService) searchHandler := appHandlers.NewSearchHandler(tmdbService)
categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService) categoriesHandler := appHandlers.NewCategoriesHandler(tmdbService)
playersHandler := appHandlers.NewPlayersHandler(cfg) playersHandler := appHandlers.NewPlayersHandler(cfg)
webtorrentHandler := appHandlers.NewWebTorrentHandler(tmdbService)
torrentsHandler := appHandlers.NewTorrentsHandler(torrentService, tmdbService) torrentsHandler := appHandlers.NewTorrentsHandler(torrentService, tmdbService)
reactionsHandler := appHandlers.NewReactionsHandler(reactionsService) reactionsHandler := appHandlers.NewReactionsHandler(reactionsService)
imagesHandler := appHandlers.NewImagesHandler() imagesHandler := appHandlers.NewImagesHandler()
@@ -64,15 +67,19 @@ func main() {
api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET") api.HandleFunc("/auth/google/login", authHandler.GoogleLogin).Methods("GET")
api.HandleFunc("/auth/google/callback", authHandler.GoogleCallback).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", categoriesHandler.GetCategories).Methods("GET")
api.HandleFunc("/categories/{id}/movies", categoriesHandler.GetMoviesByCategory).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/alloha/{imdb_id}", playersHandler.GetAllohaPlayer).Methods("GET")
api.HandleFunc("/players/lumex/{imdb_id}", playersHandler.GetLumexPlayer).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/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/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET") api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET") api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET")
@@ -107,9 +114,10 @@ func main() {
protected := api.PathPrefix("").Subrouter() protected := api.PathPrefix("").Subrouter()
protected.Use(middleware.JWTAuth(cfg.JWTSecret)) protected.Use(middleware.JWTAuth(cfg.JWTSecret))
protected.HandleFunc("/favorites", movieHandler.GetFavorites).Methods("GET") protected.HandleFunc("/favorites", favoritesHandler.GetFavorites).Methods("GET")
protected.HandleFunc("/favorites/{id}", movieHandler.AddToFavorites).Methods("POST") protected.HandleFunc("/favorites/{id}", favoritesHandler.AddToFavorites).Methods("POST")
protected.HandleFunc("/favorites/{id}", movieHandler.RemoveFromFavorites).Methods("DELETE") 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.GetProfile).Methods("GET")
protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT") protected.HandleFunc("/auth/profile", authHandler.UpdateProfile).Methods("PUT")
@@ -123,8 +131,9 @@ func main() {
corsHandler := handlers.CORS( corsHandler := handlers.CORS(
handlers.AllowedOrigins([]string{"*"}), handlers.AllowedOrigins([]string{"*"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}), 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.AllowCredentials(),
handlers.ExposedHeaders([]string{"Authorization", "Content-Type"}),
) )
var finalHandler http.Handler var finalHandler http.Handler

BIN
neomovies-api Executable file

Binary file not shown.

View File

@@ -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) vars := mux.Vars(r)
categoryID, err := strconv.Atoi(vars["id"]) categoryID, err := strconv.Atoi(vars["id"])
if err != nil { if err != nil {
@@ -67,20 +67,46 @@ func (h *CategoriesHandler) GetMoviesByCategory(w http.ResponseWriter, r *http.R
language = "ru-RU" language = "ru-RU"
} }
// Используем discover API для получения фильмов по жанру mediaType := r.URL.Query().Get("type")
movies, err := h.tmdbService.DiscoverMoviesByGenre(categoryID, page, language) if mediaType == "" {
if err != nil { mediaType = "movie" // По умолчанию фильмы для обратной совместимости
http.Error(w, err.Error(), http.StatusInternalServerError) }
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 return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(models.APIResponse{ json.NewEncoder(w).Encode(models.APIResponse{
Success: true, 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 { func generateSlug(name string) string {
// Простая функция для создания slug из названия // Простая функция для создания slug из названия
// В реальном проекте стоит использовать более сложную логику // В реальном проекте стоит использовать более сложную логику

View File

@@ -37,6 +37,8 @@ func (h *DocsHandler) GetOpenAPISpec(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") 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) json.NewEncoder(w).Encode(spec)
} }
@@ -64,6 +66,8 @@ func (h *DocsHandler) ServeDocs(w http.ResponseWriter, r *http.Request) {
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
fmt.Fprintln(w, htmlContent) 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{}{ "get": map[string]interface{}{
"summary": "Мультипоиск", "summary": "Мультипоиск",
"description": "Поиск фильмов, сериалов и актеров", "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{}{ "/api/v1/players/alloha/{imdb_id}": map[string]interface{}{
"get": map[string]interface{}{ "get": map[string]interface{}{
"summary": "Плеер Alloha", "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{}{ "/api/v1/torrents/search/{imdbId}": map[string]interface{}{
"get": map[string]interface{}{ "get": map[string]interface{}{
"summary": "Поиск торрентов", "summary": "Поиск торрентов",
@@ -715,15 +841,15 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
}, },
"/api/v1/favorites": map[string]interface{}{ "/api/v1/favorites": map[string]interface{}{
"get": map[string]interface{}{ "get": map[string]interface{}{
"summary": "Получить избранные фильмы", "summary": "Получить избранное",
"description": "Список избранных фильмов пользователя", "description": "Список избранных фильмов и сериалов пользователя",
"tags": []string{"Favorites"}, "tags": []string{"Favorites"},
"security": []map[string][]string{ "security": []map[string][]string{
{"bearerAuth": []string{}}, {"bearerAuth": []string{}},
}, },
"responses": map[string]interface{}{ "responses": map[string]interface{}{
"200": 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{}{ "/api/v1/favorites/{id}": map[string]interface{}{
"post": map[string]interface{}{ "post": map[string]interface{}{
"summary": "Добавить в избранное", "summary": "Добавить в избранное",
"description": "Добавление фильма в избранное", "description": "Добавление фильма или сериала в избранное",
"tags": []string{"Favorites"}, "tags": []string{"Favorites"},
"security": []map[string][]string{ "security": []map[string][]string{
{"bearerAuth": []string{}}, {"bearerAuth": []string{}},
@@ -742,18 +868,29 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
"in": "path", "in": "path",
"required": true, "required": true,
"schema": map[string]string{"type": "string"}, "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{}{ "responses": map[string]interface{}{
"200": map[string]interface{}{ "200": map[string]interface{}{
"description": "Фильм добавлен в избранное", "description": "Добавлено в избранное",
}, },
}, },
}, },
"delete": map[string]interface{}{ "delete": map[string]interface{}{
"summary": "Удалить из избранного", "summary": "Удалить из избранного",
"description": "Удаление фильма из избранного", "description": "Удаление фильма или сериала из избранного",
"tags": []string{"Favorites"}, "tags": []string{"Favorites"},
"security": []map[string][]string{ "security": []map[string][]string{
{"bearerAuth": []string{}}, {"bearerAuth": []string{}},
@@ -764,12 +901,58 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
"in": "path", "in": "path",
"required": true, "required": true,
"schema": map[string]string{"type": "string"}, "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{}{ "responses": map[string]interface{}{
"200": 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"}, "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
View 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},
})
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"neomovies-api/pkg/middleware"
"neomovies-api/pkg/models" "neomovies-api/pkg/models"
"neomovies-api/pkg/services" "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) { func (h *MovieHandler) GetExternalIDs(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)

578
pkg/handlers/webtorrent.go Normal file
View 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
View 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"`
}

View File

@@ -159,6 +159,31 @@ type Season struct {
SeasonNumber int `json:"season_number"` 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 { type TMDBResponse struct {
Page int `json:"page"` Page int `json:"page"`
Results []Movie `json:"results"` Results []Movie `json:"results"`

147
pkg/services/favorites.go Normal file
View 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
}

View File

@@ -1,23 +1,17 @@
package services package services
import ( import (
"context"
"strconv"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models" "neomovies-api/pkg/models"
) )
type MovieService struct { type MovieService struct {
db *mongo.Database
tmdb *TMDBService tmdb *TMDBService
} }
func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService { func NewMovieService(db *mongo.Database, tmdb *TMDBService) *MovieService {
return &MovieService{ return &MovieService{
db: db,
tmdb: tmdb, tmdb: tmdb,
} }
} }
@@ -54,56 +48,7 @@ func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBRe
return s.tmdb.GetSimilarMovies(id, page, language) 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) { func (s *MovieService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
return s.tmdb.GetMovieExternalIDs(id) return s.tmdb.GetMovieExternalIDs(id)

View File

@@ -119,6 +119,11 @@ func (s *TMDBService) SearchMulti(query string, page int, language string) (*mod
return &response, nil 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) { func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
params := url.Values{} params := url.Values{}
params.Set("query", query) params.Set("query", query)
@@ -476,4 +481,35 @@ func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string)
var response models.TMDBResponse var response models.TMDBResponse
err := s.makeRequest(endpoint, &response) err := s.makeRequest(endpoint, &response)
return &response, err 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
} }