Rewrite api to Go

This commit is contained in:
2025-08-07 13:47:42 +00:00
parent 8c47b81289
commit 8131c7db8c
56 changed files with 6484 additions and 10335 deletions

353
pkg/services/auth.go Normal file
View File

@@ -0,0 +1,353 @@
package services
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"golang.org/x/crypto/bcrypt"
"neomovies-api/pkg/models"
)
type AuthService struct {
db *mongo.Database
jwtSecret string
emailService *EmailService
}
func NewAuthService(db *mongo.Database, jwtSecret string, emailService *EmailService) *AuthService {
service := &AuthService{
db: db,
jwtSecret: jwtSecret,
emailService: emailService,
}
// Запускаем тест подключения к базе данных
go service.testDatabaseConnection()
return service
}
// testDatabaseConnection тестирует подключение к базе данных и выводит информацию о пользователях
func (s *AuthService) testDatabaseConnection() {
ctx := context.Background()
fmt.Println("=== DATABASE CONNECTION TEST ===")
// Проверяем подключение
err := s.db.Client().Ping(ctx, nil)
if err != nil {
fmt.Printf("❌ Database connection failed: %v\n", err)
return
}
fmt.Printf("✅ Database connection successful\n")
fmt.Printf("📊 Database name: %s\n", s.db.Name())
// Получаем список всех коллекций
collections, err := s.db.ListCollectionNames(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to list collections: %v\n", err)
return
}
fmt.Printf("📁 Available collections: %v\n", collections)
// Проверяем коллекцию users
collection := s.db.Collection("users")
// Подсчитываем количество документов
count, err := collection.CountDocuments(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to count users: %v\n", err)
return
}
fmt.Printf("👥 Total users in database: %d\n", count)
if count > 0 {
// Показываем всех пользователей
cursor, err := collection.Find(ctx, bson.M{})
if err != nil {
fmt.Printf("❌ Failed to find users: %v\n", err)
return
}
defer cursor.Close(ctx)
var users []bson.M
if err := cursor.All(ctx, &users); err != nil {
fmt.Printf("❌ Failed to decode users: %v\n", err)
return
}
fmt.Printf("📋 All users in database:\n")
for i, user := range users {
fmt.Printf(" %d. Email: %s, Name: %s, Verified: %v\n",
i+1,
user["email"],
user["name"],
user["verified"])
}
// Тестируем поиск конкретного пользователя
fmt.Printf("\n🔍 Testing specific user search:\n")
testEmails := []string{"neo.movies.mail@gmail.com", "fenixoffc@gmail.com", "test@example.com"}
for _, email := range testEmails {
var user bson.M
err := collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
if err != nil {
fmt.Printf(" ❌ User %s: NOT FOUND (%v)\n", email, err)
} else {
fmt.Printf(" ✅ User %s: FOUND (Name: %s, Verified: %v)\n",
email,
user["name"],
user["verified"])
}
}
}
fmt.Println("=== END DATABASE TEST ===")
}
// Генерация 6-значного кода
func (s *AuthService) generateVerificationCode() string {
return fmt.Sprintf("%06d", rand.Intn(900000)+100000)
}
func (s *AuthService) Register(req models.RegisterRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
// Проверяем, не существует ли уже пользователь с таким email
var existingUser models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&existingUser)
if err == nil {
return nil, errors.New("email already registered")
}
// Хешируем пароль
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// Генерируем код верификации
code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute) // 10 минут
// Создаем нового пользователя (НЕ ВЕРИФИЦИРОВАННОГО)
user := models.User{
ID: primitive.NewObjectID(),
Email: req.Email,
Password: string(hashedPassword),
Name: req.Name,
Favorites: []string{},
Verified: false,
VerificationCode: code,
VerificationExpires: codeExpires,
IsAdmin: false,
AdminVerified: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
_, err = collection.InsertOne(context.Background(), user)
if err != nil {
return nil, err
}
// Отправляем код верификации на email
if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code)
}
return map[string]interface{}{
"success": true,
"message": "Registered. Check email for verification code.",
}, nil
}
func (s *AuthService) Login(req models.LoginRequest) (*models.AuthResponse, error) {
collection := s.db.Collection("users")
fmt.Printf("🔍 Login attempt for email: %s\n", req.Email)
fmt.Printf("📊 Database name: %s\n", s.db.Name())
fmt.Printf("📁 Collection name: %s\n", collection.Name())
// Находим пользователя по email (точно как в JavaScript)
var user models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
if err != nil {
fmt.Printf("❌ User not found: %v\n", err)
return nil, errors.New("User not found")
}
// Проверяем верификацию email (точно как в JavaScript)
if !user.Verified {
return nil, errors.New("Account not activated. Please verify your email.")
}
// Проверяем пароль (точно как в JavaScript)
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
if err != nil {
return nil, errors.New("Invalid password")
}
// Генерируем JWT токен
token, err := s.generateJWT(user.ID.Hex())
if err != nil {
return nil, err
}
return &models.AuthResponse{
Token: token,
User: user,
}, nil
}
func (s *AuthService) GetUserByID(userID string) (*models.User, error) {
collection := s.db.Collection("users")
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return nil, err
}
var user models.User
err = collection.FindOne(context.Background(), bson.M{"_id": objectID}).Decode(&user)
if err != nil {
return nil, err
}
return &user, nil
}
func (s *AuthService) UpdateUser(userID string, updates bson.M) (*models.User, error) {
collection := s.db.Collection("users")
objectID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return nil, err
}
updates["updated_at"] = time.Now()
_, err = collection.UpdateOne(
context.Background(),
bson.M{"_id": objectID},
bson.M{"$set": updates},
)
if err != nil {
return nil, err
}
return s.GetUserByID(userID)
}
func (s *AuthService) generateJWT(userID string) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 дней
"iat": time.Now().Unix(),
"jti": uuid.New().String(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.jwtSecret))
}
// Верификация email
func (s *AuthService) VerifyEmail(req models.VerifyEmailRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
var user models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
if err != nil {
return nil, errors.New("user not found")
}
if user.Verified {
return map[string]interface{}{
"success": true,
"message": "Email already verified",
}, nil
}
// Проверяем код и срок действия
if user.VerificationCode != req.Code || user.VerificationExpires.Before(time.Now()) {
return nil, errors.New("invalid or expired verification code")
}
// Верифицируем пользователя
_, err = collection.UpdateOne(
context.Background(),
bson.M{"email": req.Email},
bson.M{
"$set": bson.M{"verified": true},
"$unset": bson.M{
"verificationCode": "",
"verificationExpires": "",
},
},
)
if err != nil {
return nil, err
}
return map[string]interface{}{
"success": true,
"message": "Email verified successfully",
}, nil
}
// Повторная отправка кода верификации
func (s *AuthService) ResendVerificationCode(req models.ResendCodeRequest) (map[string]interface{}, error) {
collection := s.db.Collection("users")
var user models.User
err := collection.FindOne(context.Background(), bson.M{"email": req.Email}).Decode(&user)
if err != nil {
return nil, errors.New("user not found")
}
if user.Verified {
return nil, errors.New("email already verified")
}
// Генерируем новый код
code := s.generateVerificationCode()
codeExpires := time.Now().Add(10 * time.Minute)
// Обновляем код в базе
_, err = collection.UpdateOne(
context.Background(),
bson.M{"email": req.Email},
bson.M{
"$set": bson.M{
"verificationCode": code,
"verificationExpires": codeExpires,
},
},
)
if err != nil {
return nil, err
}
// Отправляем новый код на email
if s.emailService != nil {
go s.emailService.SendVerificationEmail(user.Email, code)
}
return map[string]interface{}{
"success": true,
"message": "Verification code sent to your email",
}, nil
}

150
pkg/services/email.go Normal file
View File

@@ -0,0 +1,150 @@
package services
import (
"fmt"
"net/smtp"
"strings"
"neomovies-api/pkg/config"
)
type EmailService struct {
config *config.Config
}
func NewEmailService(cfg *config.Config) *EmailService {
return &EmailService{
config: cfg,
}
}
type EmailOptions struct {
To []string
Subject string
Body string
IsHTML bool
}
func (s *EmailService) SendEmail(options *EmailOptions) error {
if s.config.GmailUser == "" || s.config.GmailPassword == "" {
return fmt.Errorf("Gmail credentials not configured")
}
// Gmail SMTP конфигурация
smtpHost := "smtp.gmail.com"
smtpPort := "587"
auth := smtp.PlainAuth("", s.config.GmailUser, s.config.GmailPassword, smtpHost)
// Создаем заголовки email
headers := make(map[string]string)
headers["From"] = s.config.GmailUser
headers["To"] = strings.Join(options.To, ",")
headers["Subject"] = options.Subject
if options.IsHTML {
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=UTF-8"
}
// Формируем сообщение
message := ""
for key, value := range headers {
message += fmt.Sprintf("%s: %s\r\n", key, value)
}
message += "\r\n" + options.Body
// Отправляем email
err := smtp.SendMail(
smtpHost+":"+smtpPort,
auth,
s.config.GmailUser,
options.To,
[]byte(message),
)
return err
}
// Предустановленные шаблоны email
func (s *EmailService) SendVerificationEmail(userEmail, code string) error {
options := &EmailOptions{
To: []string{userEmail},
Subject: "Подтверждение регистрации Neo Movies",
Body: fmt.Sprintf(`
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #2196f3;">Neo Movies</h1>
<p>Здравствуйте!</p>
<p>Для завершения регистрации введите этот код:</p>
<div style="
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
text-align: center;
font-size: 24px;
letter-spacing: 4px;
margin: 20px 0;
">
%s
</div>
<p>Код действителен в течение 10 минут.</p>
<p>Если вы не регистрировались на нашем сайте, просто проигнорируйте это письмо.</p>
</div>
`, code),
IsHTML: true,
}
return s.SendEmail(options)
}
func (s *EmailService) SendPasswordResetEmail(userEmail, resetToken string) error {
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, resetToken)
options := &EmailOptions{
To: []string{userEmail},
Subject: "Сброс пароля Neo Movies",
Body: fmt.Sprintf(`
<html>
<body>
<h2>Сброс пароля</h2>
<p>Вы запросили сброс пароля для вашего аккаунта Neo Movies.</p>
<p>Нажмите на ссылку ниже, чтобы создать новый пароль:</p>
<p><a href="%s">Сбросить пароль</a></p>
<p>Ссылка действительна в течение 1 часа.</p>
<p>Если вы не запрашивали сброс пароля, проигнорируйте это сообщение.</p>
<br>
<p>С уважением,<br>Команда Neo Movies</p>
</body>
</html>
`, resetURL),
IsHTML: true,
}
return s.SendEmail(options)
}
func (s *EmailService) SendMovieRecommendationEmail(userEmail, userName string, movies []string) error {
moviesList := ""
for _, movie := range movies {
moviesList += fmt.Sprintf("<li>%s</li>", movie)
}
options := &EmailOptions{
To: []string{userEmail},
Subject: "Новые рекомендации фильмов от Neo Movies",
Body: fmt.Sprintf(`
<html>
<body>
<h2>Привет, %s!</h2>
<p>У нас есть новые рекомендации фильмов специально для вас:</p>
<ul>%s</ul>
<p>Заходите в приложение, чтобы узнать больше деталей!</p>
<br>
<p>С уважением,<br>Команда Neo Movies</p>
</body>
</html>
`, userName, moviesList),
IsHTML: true,
}
return s.SendEmail(options)
}

110
pkg/services/movie.go Normal file
View File

@@ -0,0 +1,110 @@
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,
}
}
func (s *MovieService) Search(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
return s.tmdb.SearchMovies(query, page, language, region, year)
}
func (s *MovieService) GetByID(id int, language string) (*models.Movie, error) {
return s.tmdb.GetMovie(id, language)
}
func (s *MovieService) GetPopular(page int, language, region string) (*models.TMDBResponse, error) {
return s.tmdb.GetPopularMovies(page, language, region)
}
func (s *MovieService) GetTopRated(page int, language, region string) (*models.TMDBResponse, error) {
return s.tmdb.GetTopRatedMovies(page, language, region)
}
func (s *MovieService) GetUpcoming(page int, language, region string) (*models.TMDBResponse, error) {
return s.tmdb.GetUpcomingMovies(page, language, region)
}
func (s *MovieService) GetNowPlaying(page int, language, region string) (*models.TMDBResponse, error) {
return s.tmdb.GetNowPlayingMovies(page, language, region)
}
func (s *MovieService) GetRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
return s.tmdb.GetMovieRecommendations(id, page, language)
}
func (s *MovieService) GetSimilar(id, page int, language string) (*models.TMDBResponse, error) {
return s.tmdb.GetSimilarMovies(id, page, language)
}
func (s *MovieService) 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)
}

212
pkg/services/reactions.go Normal file
View File

@@ -0,0 +1,212 @@
package services
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"neomovies-api/pkg/models"
)
type ReactionsService struct {
db *mongo.Database
client *http.Client
}
func NewReactionsService(db *mongo.Database) *ReactionsService {
return &ReactionsService{
db: db,
client: &http.Client{},
}
}
const CUB_API_URL = "https://cub.rip/api"
var VALID_REACTIONS = []string{"fire", "nice", "think", "bore", "shit"}
// Получить счетчики реакций для медиа из внешнего API (cub.rip)
func (s *ReactionsService) GetReactionCounts(mediaType, mediaID string) (*models.ReactionCounts, error) {
cubID := fmt.Sprintf("%s_%s", mediaType, mediaID)
resp, err := s.client.Get(fmt.Sprintf("%s/reactions/get/%s", CUB_API_URL, cubID))
if err != nil {
return &models.ReactionCounts{}, nil // Возвращаем пустые счетчики при ошибке
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &models.ReactionCounts{}, nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return &models.ReactionCounts{}, nil
}
var response struct {
Result []struct {
Type string `json:"type"`
Counter int `json:"counter"`
} `json:"result"`
}
if err := json.Unmarshal(body, &response); err != nil {
return &models.ReactionCounts{}, nil
}
// Преобразуем в нашу структуру
counts := &models.ReactionCounts{}
for _, reaction := range response.Result {
switch reaction.Type {
case "fire":
counts.Fire = reaction.Counter
case "nice":
counts.Nice = reaction.Counter
case "think":
counts.Think = reaction.Counter
case "bore":
counts.Bore = reaction.Counter
case "shit":
counts.Shit = reaction.Counter
}
}
return counts, nil
}
// Получить реакцию пользователя для медиа
func (s *ReactionsService) GetUserReaction(userID, mediaType, mediaID string) (*models.Reaction, error) {
collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
var reaction models.Reaction
err := collection.FindOne(context.Background(), bson.M{
"userId": userID,
"mediaId": fullMediaID,
}).Decode(&reaction)
if err == mongo.ErrNoDocuments {
return nil, nil // Реакции нет
}
return &reaction, err
}
// Установить реакцию пользователя
func (s *ReactionsService) SetUserReaction(userID, mediaType, mediaID, reactionType string) error {
// Проверяем валидность типа реакции
if !s.isValidReactionType(reactionType) {
return fmt.Errorf("invalid reaction type: %s", reactionType)
}
collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
// Создаем или обновляем реакцию
filter := bson.M{
"userId": userID,
"mediaId": fullMediaID,
}
reaction := models.Reaction{
UserID: userID,
MediaID: fullMediaID,
Type: reactionType,
Created: time.Now().Format(time.RFC3339),
}
update := bson.M{
"$set": reaction,
}
upsert := true
_, err := collection.UpdateOne(context.Background(), filter, update, &options.UpdateOptions{
Upsert: &upsert,
})
if err != nil {
return err
}
// Отправляем реакцию в cub.rip API
go s.sendReactionToCub(fullMediaID, reactionType)
return nil
}
// Удалить реакцию пользователя
func (s *ReactionsService) RemoveUserReaction(userID, mediaType, mediaID string) error {
collection := s.db.Collection("reactions")
fullMediaID := fmt.Sprintf("%s_%s", mediaType, mediaID)
_, err := collection.DeleteOne(context.Background(), bson.M{
"userId": userID,
"mediaId": fullMediaID,
})
return err
}
// Получить все реакции пользователя
func (s *ReactionsService) GetUserReactions(userID string, limit int) ([]models.Reaction, error) {
collection := s.db.Collection("reactions")
ctx := context.Background()
cursor, err := collection.Find(ctx, bson.M{"userId": userID})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var reactions []models.Reaction
if err := cursor.All(ctx, &reactions); err != nil {
return nil, err
}
return reactions, nil
}
func (s *ReactionsService) isValidReactionType(reactionType string) bool {
for _, valid := range VALID_REACTIONS {
if valid == reactionType {
return true
}
}
return false
}
// Отправка реакции в cub.rip API (асинхронно)
func (s *ReactionsService) sendReactionToCub(mediaID, reactionType string) {
// Формируем запрос к cub.rip API
url := fmt.Sprintf("%s/reactions/set", CUB_API_URL)
data := map[string]string{
"mediaId": mediaID,
"type": reactionType,
}
_, err := json.Marshal(data)
if err != nil {
return
}
// В данном случае мы отправляем простой POST запрос
// В будущем можно доработать для отправки JSON данных
resp, err := s.client.Get(fmt.Sprintf("%s?mediaId=%s&type=%s", url, mediaID, reactionType))
if err != nil {
return
}
defer resp.Body.Close()
// Логируем результат (в продакшене лучше использовать структурированное логирование)
if resp.StatusCode == http.StatusOK {
fmt.Printf("Reaction sent to cub.rip: %s - %s\n", mediaID, reactionType)
}
}

479
pkg/services/tmdb.go Normal file
View File

@@ -0,0 +1,479 @@
package services
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"neomovies-api/pkg/models"
)
type TMDBService struct {
accessToken string
baseURL string
client *http.Client
}
func NewTMDBService(accessToken string) *TMDBService {
return &TMDBService{
accessToken: accessToken,
baseURL: "https://api.themoviedb.org/3",
client: &http.Client{},
}
}
func (s *TMDBService) makeRequest(endpoint string, target interface{}) error {
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return err
}
// Используем Bearer токен вместо API key в query параметрах
req.Header.Set("Authorization", "Bearer "+s.accessToken)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("TMDB API error: %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(target)
}
func (s *TMDBService) SearchMovies(query string, page int, language, region string, year int) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("query", query)
params.Set("page", strconv.Itoa(page))
params.Set("include_adult", "false")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
if year > 0 {
params.Set("year", strconv.Itoa(year))
}
endpoint := fmt.Sprintf("%s/search/movie?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) SearchMulti(query string, page int, language string) (*models.MultiSearchResponse, error) {
params := url.Values{}
params.Set("query", query)
params.Set("page", strconv.Itoa(page))
params.Set("include_adult", "false")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/search/multi?%s", s.baseURL, params.Encode())
var response models.MultiSearchResponse
err := s.makeRequest(endpoint, &response)
if err != nil {
return nil, err
}
// Фильтруем результаты: убираем "person", и без названия
filteredResults := make([]models.MultiSearchResult, 0)
for _, result := range response.Results {
if result.MediaType == "person" {
continue
}
hasTitle := false
if result.MediaType == "movie" && result.Title != "" {
hasTitle = true
} else if result.MediaType == "tv" && result.Name != "" {
hasTitle = true
}
if hasTitle {
filteredResults = append(filteredResults, result)
}
}
response.Results = filteredResults
response.TotalResults = len(filteredResults)
return &response, nil
}
func (s *TMDBService) SearchTVShows(query string, page int, language string, firstAirDateYear int) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("query", query)
params.Set("page", strconv.Itoa(page))
params.Set("include_adult", "false")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if firstAirDateYear > 0 {
params.Set("first_air_date_year", strconv.Itoa(firstAirDateYear))
}
endpoint := fmt.Sprintf("%s/search/tv?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetMovie(id int, language string) (*models.Movie, error) {
params := url.Values{}
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/movie/%d?%s", s.baseURL, id, params.Encode())
var movie models.Movie
err := s.makeRequest(endpoint, &movie)
return &movie, err
}
func (s *TMDBService) GetTVShow(id int, language string) (*models.TVShow, error) {
params := url.Values{}
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/%d?%s", s.baseURL, id, params.Encode())
var tvShow models.TVShow
err := s.makeRequest(endpoint, &tvShow)
return &tvShow, err
}
func (s *TMDBService) GetGenres(mediaType string, language string) (*models.GenresResponse, error) {
params := url.Values{}
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/genre/%s/list?%s", s.baseURL, mediaType, params.Encode())
var response models.GenresResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetAllGenres() (*models.GenresResponse, error) {
// Получаем жанры фильмов
movieGenres, err := s.GetGenres("movie", "ru-RU")
if err != nil {
return nil, err
}
// Получаем жанры сериалов
tvGenres, err := s.GetGenres("tv", "ru-RU")
if err != nil {
return nil, err
}
// Объединяем жанры, убирая дубликаты
allGenres := make(map[int]models.Genre)
for _, genre := range movieGenres.Genres {
allGenres[genre.ID] = genre
}
for _, genre := range tvGenres.Genres {
allGenres[genre.ID] = genre
}
// Преобразуем обратно в слайс
var genres []models.Genre
for _, genre := range allGenres {
genres = append(genres, genre)
}
return &models.GenresResponse{Genres: genres}, nil
}
func (s *TMDBService) GetPopularMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/popular?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetTopRatedMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/top_rated?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetUpcomingMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/upcoming?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetNowPlayingMovies(page int, language, region string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
if region != "" {
params.Set("region", region)
}
endpoint := fmt.Sprintf("%s/movie/now_playing?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetMovieRecommendations(id, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/movie/%d/recommendations?%s", s.baseURL, id, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetSimilarMovies(id, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/movie/%d/similar?%s", s.baseURL, id, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetPopularTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/popular?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetTopRatedTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/top_rated?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetOnTheAirTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/on_the_air?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetAiringTodayTVShows(page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/airing_today?%s", s.baseURL, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetTVRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/%d/recommendations?%s", s.baseURL, id, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetSimilarTVShows(id, page int, language string) (*models.TMDBTVResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/tv/%d/similar?%s", s.baseURL, id, params.Encode())
var response models.TMDBTVResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}
func (s *TMDBService) GetMovieExternalIDs(id int) (*models.ExternalIDs, error) {
endpoint := fmt.Sprintf("%s/movie/%d/external_ids", s.baseURL, id)
var ids models.ExternalIDs
err := s.makeRequest(endpoint, &ids)
return &ids, err
}
func (s *TMDBService) GetTVExternalIDs(id int) (*models.ExternalIDs, error) {
endpoint := fmt.Sprintf("%s/tv/%d/external_ids", s.baseURL, id)
var ids models.ExternalIDs
err := s.makeRequest(endpoint, &ids)
return &ids, err
}
func (s *TMDBService) DiscoverMoviesByGenre(genreID, page int, language string) (*models.TMDBResponse, error) {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
params.Set("with_genres", strconv.Itoa(genreID))
params.Set("sort_by", "popularity.desc")
if language != "" {
params.Set("language", language)
} else {
params.Set("language", "ru-RU")
}
endpoint := fmt.Sprintf("%s/discover/movie?%s", s.baseURL, params.Encode())
var response models.TMDBResponse
err := s.makeRequest(endpoint, &response)
return &response, err
}

832
pkg/services/torrent.go Normal file
View File

@@ -0,0 +1,832 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"neomovies-api/pkg/models"
)
type TorrentService struct {
client *http.Client
baseURL string
apiKey string
}
func NewTorrentService() *TorrentService {
return &TorrentService{
client: &http.Client{Timeout: 8 * time.Second},
baseURL: "http://redapi.cfhttp.top",
apiKey: "", // Может быть установлен через переменные окружения
}
}
// SearchTorrents - основной метод поиска торрентов через RedAPI
func (s *TorrentService) SearchTorrents(params map[string]string) (*models.TorrentSearchResponse, error) {
searchParams := url.Values{}
// Добавляем все параметры поиска
for key, value := range params {
if value != "" {
if key == "category" {
searchParams.Add("category[]", value)
} else {
searchParams.Add(key, value)
}
}
}
if s.apiKey != "" {
searchParams.Add("apikey", s.apiKey)
}
searchURL := fmt.Sprintf("%s/api/v2.0/indexers/all/results?%s", s.baseURL, searchParams.Encode())
resp, err := s.client.Get(searchURL)
if err != nil {
return nil, fmt.Errorf("failed to search torrents: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var redAPIResponse models.RedAPIResponse
if err := json.Unmarshal(body, &redAPIResponse); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
results := s.parseRedAPIResults(redAPIResponse)
return &models.TorrentSearchResponse{
Query: params["query"],
Results: results,
Total: len(results),
}, nil
}
// parseRedAPIResults преобразует результаты RedAPI в наш формат
func (s *TorrentService) parseRedAPIResults(data models.RedAPIResponse) []models.TorrentResult {
var results []models.TorrentResult
for _, torrent := range data.Results {
// Обрабатываем размер - может быть строкой или числом
var sizeStr string
switch v := torrent.Size.(type) {
case string:
sizeStr = v
case float64:
sizeStr = fmt.Sprintf("%.0f", v)
case int:
sizeStr = fmt.Sprintf("%d", v)
default:
sizeStr = ""
}
result := models.TorrentResult{
Title: torrent.Title,
Tracker: torrent.Tracker,
Size: sizeStr,
Seeders: torrent.Seeders,
Peers: torrent.Peers,
MagnetLink: torrent.MagnetUri,
PublishDate: torrent.PublishDate,
Category: torrent.CategoryDesc,
Details: torrent.Details,
Source: "RedAPI",
}
// Добавляем информацию из Info если она есть
if torrent.Info != nil {
// Обрабатываем качество - может быть строкой или числом
switch v := torrent.Info.Quality.(type) {
case string:
result.Quality = v
case float64:
result.Quality = fmt.Sprintf("%.0fp", v)
case int:
result.Quality = fmt.Sprintf("%dp", v)
}
result.Voice = torrent.Info.Voices
result.Types = torrent.Info.Types
result.Seasons = torrent.Info.Seasons
}
// Если качество не определено через Info, пытаемся извлечь из названия
if result.Quality == "" {
result.Quality = s.ExtractQuality(result.Title)
}
results = append(results, result)
}
return results
}
// SearchTorrentsByIMDbID - поиск по IMDB ID с поддержкой всех функций
func (s *TorrentService) SearchTorrentsByIMDbID(tmdbService *TMDBService, imdbID, mediaType string, options *models.TorrentSearchOptions) (*models.TorrentSearchResponse, error) {
// Получаем информацию о фильме/сериале из TMDB
title, originalTitle, year, err := s.getTitleFromTMDB(tmdbService, imdbID, mediaType)
if err != nil {
return nil, fmt.Errorf("failed to get title from TMDB: %w", err)
}
// Формируем параметры поиска
params := make(map[string]string)
params["imdb"] = imdbID
params["title"] = title
params["title_original"] = originalTitle
params["year"] = year
// Устанавливаем тип контента и категорию
switch mediaType {
case "movie":
params["is_serial"] = "1"
params["category"] = "2000"
case "tv", "series":
params["is_serial"] = "2"
params["category"] = "5000"
case "anime":
params["is_serial"] = "5"
params["category"] = "5070"
default:
params["is_serial"] = "1"
params["category"] = "2000"
}
// Добавляем сезон если указан
if options != nil && options.Season != nil {
params["season"] = strconv.Itoa(*options.Season)
}
// Выполняем поиск
response, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
// Применяем фильтрацию
if options != nil {
response.Results = s.FilterByContentType(response.Results, options.ContentType)
response.Results = s.FilterTorrents(response.Results, options)
response.Results = s.sortTorrents(response.Results, options.SortBy, options.SortOrder)
response.Total = len(response.Results)
}
return response, nil
}
// SearchMovies - поиск фильмов с дополнительной фильтрацией
func (s *TorrentService) SearchMovies(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
params := map[string]string{
"title": title,
"title_original": originalTitle,
"year": year,
"is_serial": "1",
"category": "2000",
}
response, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
response.Results = s.FilterByContentType(response.Results, "movie")
response.Total = len(response.Results)
return response, nil
}
// SearchSeries - поиск сериалов с поддержкой fallback и фильтрации по сезону
func (s *TorrentService) SearchSeries(title, originalTitle, year string, season *int) (*models.TorrentSearchResponse, error) {
params := map[string]string{
"title": title,
"title_original": originalTitle,
"year": year,
"is_serial": "2",
"category": "5000",
}
if season != nil {
params["season"] = strconv.Itoa(*season)
}
response, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
// Если указан сезон и результатов мало, делаем fallback-поиск без сезона и фильтруем на клиенте
if season != nil && len(response.Results) < 5 {
paramsNoSeason := map[string]string{
"title": title,
"title_original": originalTitle,
"year": year,
"is_serial": "2",
"category": "5000",
}
fallbackResp, err := s.SearchTorrents(paramsNoSeason)
if err == nil {
filtered := s.filterBySeason(fallbackResp.Results, *season)
// Объединяем и убираем дубликаты по MagnetLink
all := append(response.Results, filtered...)
unique := make([]models.TorrentResult, 0, len(all))
seen := make(map[string]bool)
for _, t := range all {
if !seen[t.MagnetLink] {
unique = append(unique, t)
seen[t.MagnetLink] = true
}
}
response.Results = unique
}
}
response.Results = s.FilterByContentType(response.Results, "serial")
response.Total = len(response.Results)
return response, nil
}
// filterBySeason - фильтрация результатов по сезону (аналогично JS)
func (s *TorrentService) filterBySeason(results []models.TorrentResult, season int) []models.TorrentResult {
if season == 0 {
return results
}
filtered := make([]models.TorrentResult, 0, len(results))
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
for _, torrent := range results {
found := false
// Проверяем поле seasons
for _, s := range torrent.Seasons {
if s == season {
found = true
break
}
}
if found {
filtered = append(filtered, torrent)
continue
}
// Проверяем в названии
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
for _, match := range matches {
seasonNumber := 0
if match[1] != "" {
seasonNumber, _ = strconv.Atoi(match[1])
} else if match[2] != "" {
seasonNumber, _ = strconv.Atoi(match[2])
}
if seasonNumber == season {
filtered = append(filtered, torrent)
break
}
}
}
return filtered
}
// SearchAnime - поиск аниме
func (s *TorrentService) SearchAnime(title, originalTitle, year string) (*models.TorrentSearchResponse, error) {
params := map[string]string{
"title": title,
"title_original": originalTitle,
"year": year,
"is_serial": "5",
"category": "5070",
}
response, err := s.SearchTorrents(params)
if err != nil {
return nil, err
}
response.Results = s.FilterByContentType(response.Results, "anime")
response.Total = len(response.Results)
return response, nil
}
// AllohaResponse - структура ответа от Alloha API
type AllohaResponse struct {
Status string `json:"status"`
Data struct {
Name string `json:"name"`
OriginalName string `json:"original_name"`
Year int `json:"year"`
Category int `json:"category"` // 1-фильм, 2-сериал
} `json:"data"`
}
// getMovieInfoByIMDB - получение информации через Alloha API (как в JavaScript версии)
func (s *TorrentService) getMovieInfoByIMDB(imdbID string) (string, string, string, error) {
// Используем тот же токен что и в JavaScript версии
endpoint := fmt.Sprintf("https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&imdb=%s", imdbID)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return "", "", "", err
}
resp, err := s.client.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", err
}
var allohaResponse AllohaResponse
if err := json.Unmarshal(body, &allohaResponse); err != nil {
return "", "", "", err
}
if allohaResponse.Status != "success" {
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
}
title := allohaResponse.Data.Name
originalTitle := allohaResponse.Data.OriginalName
year := ""
if allohaResponse.Data.Year > 0 {
year = strconv.Itoa(allohaResponse.Data.Year)
}
return title, originalTitle, year, nil
}
// getTitleFromTMDB - получение информации из TMDB (с fallback на Alloha API)
func (s *TorrentService) getTitleFromTMDB(tmdbService *TMDBService, imdbID, mediaType string) (string, string, string, error) {
// Сначала пробуем Alloha API (как в JavaScript версии)
title, originalTitle, year, err := s.getMovieInfoByIMDB(imdbID)
if err == nil {
return title, originalTitle, year, nil
}
// Если Alloha API не работает, пробуем TMDB API
endpoint := fmt.Sprintf("https://api.themoviedb.org/3/find/%s", imdbID)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return "", "", "", err
}
params := url.Values{}
params.Set("external_source", "imdb_id")
params.Set("language", "ru-RU")
req.URL.RawQuery = params.Encode()
req.Header.Set("Authorization", "Bearer "+tmdbService.accessToken)
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", err
}
var findResponse struct {
MovieResults []struct {
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
ReleaseDate string `json:"release_date"`
} `json:"movie_results"`
TVResults []struct {
Name string `json:"name"`
OriginalName string `json:"original_name"`
FirstAirDate string `json:"first_air_date"`
} `json:"tv_results"`
}
if err := json.Unmarshal(body, &findResponse); err != nil {
return "", "", "", err
}
if mediaType == "movie" && len(findResponse.MovieResults) > 0 {
movie := findResponse.MovieResults[0]
title := movie.Title
originalTitle := movie.OriginalTitle
year := ""
if movie.ReleaseDate != "" {
year = movie.ReleaseDate[:4]
}
return title, originalTitle, year, nil
}
if (mediaType == "tv" || mediaType == "series") && len(findResponse.TVResults) > 0 {
tv := findResponse.TVResults[0]
title := tv.Name
originalTitle := tv.OriginalName
year := ""
if tv.FirstAirDate != "" {
year = tv.FirstAirDate[:4]
}
return title, originalTitle, year, nil
}
return "", "", "", fmt.Errorf("no results found for IMDB ID: %s", imdbID)
}
// FilterByContentType - фильтрация по типу контента
func (s *TorrentService) FilterByContentType(results []models.TorrentResult, contentType string) []models.TorrentResult {
if contentType == "" {
return results
}
var filtered []models.TorrentResult
for _, torrent := range results {
// Фильтрация по полю types, если оно есть
if len(torrent.Types) > 0 {
switch contentType {
case "movie":
if s.containsAny(torrent.Types, []string{"movie", "multfilm", "documovie"}) {
filtered = append(filtered, torrent)
}
case "serial":
if s.containsAny(torrent.Types, []string{"serial", "multserial", "docuserial", "tvshow"}) {
filtered = append(filtered, torrent)
}
case "anime":
if s.contains(torrent.Types, "anime") {
filtered = append(filtered, torrent)
}
}
continue
}
// Фильтрация по названию, если types недоступно
title := strings.ToLower(torrent.Title)
switch contentType {
case "movie":
if !regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
filtered = append(filtered, torrent)
}
case "serial":
if regexp.MustCompile(`(?i)(сезон|серии|series|season|эпизод)`).MatchString(title) {
filtered = append(filtered, torrent)
}
case "anime":
if torrent.Category == "TV/Anime" || regexp.MustCompile(`(?i)anime`).MatchString(title) {
filtered = append(filtered, torrent)
}
default:
filtered = append(filtered, torrent)
}
}
return filtered
}
// FilterTorrents - фильтрация торрентов по опциям
func (s *TorrentService) FilterTorrents(torrents []models.TorrentResult, options *models.TorrentSearchOptions) []models.TorrentResult {
if options == nil {
return torrents
}
var filtered []models.TorrentResult
for _, torrent := range torrents {
// Фильтрация по качеству
if len(options.Quality) > 0 {
found := false
for _, quality := range options.Quality {
if strings.EqualFold(torrent.Quality, quality) {
found = true
break
}
}
if !found {
continue
}
}
// Фильтрация по минимальному качеству
if options.MinQuality != "" && !s.qualityMeetsMinimum(torrent.Quality, options.MinQuality) {
continue
}
// Фильтрация по максимальному качеству
if options.MaxQuality != "" && !s.qualityMeetsMaximum(torrent.Quality, options.MaxQuality) {
continue
}
// Исключение качеств
if len(options.ExcludeQualities) > 0 {
excluded := false
for _, excludeQuality := range options.ExcludeQualities {
if strings.EqualFold(torrent.Quality, excludeQuality) {
excluded = true
break
}
}
if excluded {
continue
}
}
// Фильтрация по HDR
if options.HDR != nil {
hasHDR := regexp.MustCompile(`(?i)(hdr|dolby.vision|dv)`).MatchString(torrent.Title)
if *options.HDR != hasHDR {
continue
}
}
// Фильтрация по HEVC
if options.HEVC != nil {
hasHEVC := regexp.MustCompile(`(?i)(hevc|h\.265|x265)`).MatchString(torrent.Title)
if *options.HEVC != hasHEVC {
continue
}
}
// Фильтрация по сезону (дополнительная на клиенте)
if options.Season != nil {
if !s.matchesSeason(torrent, *options.Season) {
continue
}
}
filtered = append(filtered, torrent)
}
return filtered
}
// matchesSeason - проверка соответствия сезону
func (s *TorrentService) matchesSeason(torrent models.TorrentResult, season int) bool {
// Проверяем в поле seasons
for _, s := range torrent.Seasons {
if s == season {
return true
}
}
// Проверяем в названии
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
for _, match := range matches {
seasonNumber := 0
if match[1] != "" {
seasonNumber, _ = strconv.Atoi(match[1])
} else if match[2] != "" {
seasonNumber, _ = strconv.Atoi(match[2])
}
if seasonNumber == season {
return true
}
}
return false
}
// ExtractQuality - извлечение качества из названия
func (s *TorrentService) ExtractQuality(title string) string {
title = strings.ToUpper(title)
qualityPatterns := []struct {
pattern string
quality string
}{
{`2160P|4K`, "2160p"},
{`1440P`, "1440p"},
{`1080P`, "1080p"},
{`720P`, "720p"},
{`480P`, "480p"},
{`360P`, "360p"},
}
for _, qp := range qualityPatterns {
if matched, _ := regexp.MatchString(qp.pattern, title); matched {
if qp.quality == "2160p" {
return "4K"
}
return qp.quality
}
}
return "Unknown"
}
// sortTorrents - сортировка результатов
func (s *TorrentService) sortTorrents(torrents []models.TorrentResult, sortBy, sortOrder string) []models.TorrentResult {
if sortBy == "" {
sortBy = "seeders"
}
if sortOrder == "" {
sortOrder = "desc"
}
sort.Slice(torrents, func(i, j int) bool {
var less bool
switch sortBy {
case "seeders":
less = torrents[i].Seeders < torrents[j].Seeders
case "size":
less = s.compareSizes(torrents[i].Size, torrents[j].Size)
case "date":
less = torrents[i].PublishDate < torrents[j].PublishDate
default:
less = torrents[i].Seeders < torrents[j].Seeders
}
if sortOrder == "asc" {
return less
}
return !less
})
return torrents
}
// GroupByQuality - группировка по качеству
func (s *TorrentService) GroupByQuality(results []models.TorrentResult) map[string][]models.TorrentResult {
groups := make(map[string][]models.TorrentResult)
for _, torrent := range results {
quality := torrent.Quality
if quality == "" {
quality = "unknown"
}
// Объединяем 4K и 2160p в одну группу
if quality == "2160p" {
quality = "4K"
}
groups[quality] = append(groups[quality], torrent)
}
// Сортируем торренты внутри каждой группы по сидам
for quality := range groups {
sort.Slice(groups[quality], func(i, j int) bool {
return groups[quality][i].Seeders > groups[quality][j].Seeders
})
}
return groups
}
// GroupBySeason - группировка по сезонам
func (s *TorrentService) GroupBySeason(results []models.TorrentResult) map[string][]models.TorrentResult {
groups := make(map[string][]models.TorrentResult)
for _, torrent := range results {
seasons := make(map[int]bool)
// Извлекаем сезоны из поля seasons
for _, season := range torrent.Seasons {
seasons[season] = true
}
// Извлекаем сезоны из названия
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
for _, match := range matches {
seasonNumber := 0
if match[1] != "" {
seasonNumber, _ = strconv.Atoi(match[1])
} else if match[2] != "" {
seasonNumber, _ = strconv.Atoi(match[2])
}
if seasonNumber > 0 {
seasons[seasonNumber] = true
}
}
// Если сезоны не найдены, добавляем в группу "unknown"
if len(seasons) == 0 {
groups["Неизвестно"] = append(groups["Неизвестно"], torrent)
} else {
// Добавляем торрент во все соответствующие группы сезонов
for season := range seasons {
seasonKey := fmt.Sprintf("Сезон %d", season)
// Проверяем дубликаты
found := false
for _, existing := range groups[seasonKey] {
if existing.MagnetLink == torrent.MagnetLink {
found = true
break
}
}
if !found {
groups[seasonKey] = append(groups[seasonKey], torrent)
}
}
}
}
// Сортируем торренты внутри каждой группы по сидам
for season := range groups {
sort.Slice(groups[season], func(i, j int) bool {
return groups[season][i].Seeders > groups[season][j].Seeders
})
}
return groups
}
// GetAvailableSeasons - получение доступных сезонов для сериала
func (s *TorrentService) GetAvailableSeasons(title, originalTitle, year string) ([]int, error) {
response, err := s.SearchSeries(title, originalTitle, year, nil)
if err != nil {
return nil, err
}
seasonsSet := make(map[int]bool)
for _, torrent := range response.Results {
// Извлекаем из поля seasons
for _, season := range torrent.Seasons {
seasonsSet[season] = true
}
// Извлекаем из названия
seasonRegex := regexp.MustCompile(`(?i)(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон`)
matches := seasonRegex.FindAllStringSubmatch(torrent.Title, -1)
for _, match := range matches {
seasonNumber := 0
if match[1] != "" {
seasonNumber, _ = strconv.Atoi(match[1])
} else if match[2] != "" {
seasonNumber, _ = strconv.Atoi(match[2])
}
if seasonNumber > 0 {
seasonsSet[seasonNumber] = true
}
}
}
var seasons []int
for season := range seasonsSet {
seasons = append(seasons, season)
}
sort.Ints(seasons)
return seasons, nil
}
// Вспомогательные функции
func (s *TorrentService) qualityMeetsMinimum(quality, minQuality string) bool {
qualityOrder := map[string]int{
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
}
currentLevel := qualityOrder[strings.ToLower(quality)]
minLevel := qualityOrder[strings.ToLower(minQuality)]
return currentLevel >= minLevel
}
func (s *TorrentService) qualityMeetsMaximum(quality, maxQuality string) bool {
qualityOrder := map[string]int{
"360p": 1, "480p": 2, "720p": 3, "1080p": 4, "1440p": 5, "4K": 6, "2160p": 6,
}
currentLevel := qualityOrder[strings.ToLower(quality)]
maxLevel := qualityOrder[strings.ToLower(maxQuality)]
return currentLevel <= maxLevel
}
func (s *TorrentService) compareSizes(size1, size2 string) bool {
// Простое сравнение размеров (можно улучшить)
return len(size1) < len(size2)
}
func (s *TorrentService) contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
func (s *TorrentService) containsAny(slice []string, items []string) bool {
for _, item := range items {
if s.contains(slice, item) {
return true
}
}
return false
}

55
pkg/services/tv.go Normal file
View File

@@ -0,0 +1,55 @@
package services
import (
"go.mongodb.org/mongo-driver/mongo"
"neomovies-api/pkg/models"
)
type TVService struct {
db *mongo.Database
tmdb *TMDBService
}
func NewTVService(db *mongo.Database, tmdb *TMDBService) *TVService {
return &TVService{
db: db,
tmdb: tmdb,
}
}
func (s *TVService) Search(query string, page int, language string, year int) (*models.TMDBTVResponse, error) {
return s.tmdb.SearchTVShows(query, page, language, year)
}
func (s *TVService) GetByID(id int, language string) (*models.TVShow, error) {
return s.tmdb.GetTVShow(id, language)
}
func (s *TVService) GetPopular(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetPopularTVShows(page, language)
}
func (s *TVService) GetTopRated(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetTopRatedTVShows(page, language)
}
func (s *TVService) GetOnTheAir(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetOnTheAirTVShows(page, language)
}
func (s *TVService) GetAiringToday(page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetAiringTodayTVShows(page, language)
}
func (s *TVService) GetRecommendations(id, page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetTVRecommendations(id, page, language)
}
func (s *TVService) GetSimilar(id, page int, language string) (*models.TMDBTVResponse, error) {
return s.tmdb.GetSimilarTVShows(id, page, language)
}
func (s *TVService) GetExternalIDs(id int) (*models.ExternalIDs, error) {
return s.tmdb.GetTVExternalIDs(id)
}