Добавить тесты репозитория файловой системы и реализовать функциональность журналирования файлов.
- Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы. - Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов. - Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов. - Добавить тесты для нормализации URL в репозиторий RuTracker. - Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер. - Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов.
This commit is contained in:
47
internal/infrastructure/config/repository.go
Normal file
47
internal/infrastructure/config/repository.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"audio-catalyst/internal/domain/entities"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Repository реализация ConfigRepository
|
||||
type Repository struct{}
|
||||
|
||||
// NewRepository создает новый репозиторий конфигурации
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
// Load загружает конфигурацию из файла
|
||||
func (r *Repository) Load(filename string) (*entities.Config, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось прочитать файл конфигурации: %w", err)
|
||||
}
|
||||
|
||||
var config entities.Config
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("не удалось разобрать конфигурацию: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// Save сохраняет конфигурацию в файл
|
||||
func (r *Repository) Save(filename string, config *entities.Config) error {
|
||||
data, err := yaml.Marshal(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось сериализовать конфигурацию: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filename, data, 0644); err != nil {
|
||||
return fmt.Errorf("не удалось записать файл конфигурации: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
286
internal/infrastructure/filesystem/repository.go
Normal file
286
internal/infrastructure/filesystem/repository.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"audio-catalyst/internal/domain/entities"
|
||||
)
|
||||
|
||||
// Repository реализация AudioBookRepository
|
||||
type Repository struct{}
|
||||
|
||||
// NewRepository создает новый репозиторий файловой системы
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
// ScanDirectory сканирует директорию на наличие аудиокниг
|
||||
func (r *Repository) ScanDirectory(rootDir string) ([]entities.AudioBook, error) {
|
||||
var audioBooks []entities.AudioBook
|
||||
|
||||
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
mp3Files, err := r.findMP3Files(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(mp3Files) > 0 {
|
||||
book := entities.AudioBook{
|
||||
Path: path,
|
||||
Title: filepath.Base(path),
|
||||
MP3Files: mp3Files,
|
||||
CoverFile: r.findCoverFile(path),
|
||||
Description: r.findDescriptionFile(path),
|
||||
}
|
||||
audioBooks = append(audioBooks, book)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return audioBooks, err
|
||||
}
|
||||
|
||||
// SaveMetadata сохраняет метаданные аудиокниги
|
||||
func (r *Repository) SaveMetadata(bookPath string, metadata *entities.AudioBookMetadata) error {
|
||||
metadataFile := filepath.Join(bookPath, "metadata.json")
|
||||
|
||||
data, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка сериализации метаданных: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(metadataFile, data, 0644); err != nil {
|
||||
return fmt.Errorf("ошибка записи метаданных: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadCover загружает обложку по URL
|
||||
func (r *Repository) DownloadCover(coverURL, bookPath string) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("пустой URL обложки")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
req, err := http.NewRequest("GET", coverURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка создания запроса: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
// Поставим корректный Referer, если URL — с rutracker.org
|
||||
if u, err := url.Parse(coverURL); err == nil && strings.Contains(u.Host, "rutracker.org") {
|
||||
req.Header.Set("Referer", "https://rutracker.org/forum/")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка загрузки обложки: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP статус: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Определяем расширение файла
|
||||
fileExt := ".jpg"
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||
if exts, _ := mime.ExtensionsByType(ct); len(exts) > 0 {
|
||||
fileExt = exts[0]
|
||||
} else if strings.Contains(ct, "png") {
|
||||
fileExt = ".png"
|
||||
} else if strings.Contains(ct, "gif") {
|
||||
fileExt = ".gif"
|
||||
}
|
||||
}
|
||||
|
||||
// Если в URL есть расширение — доверим ему
|
||||
if u, err := url.Parse(coverURL); err == nil {
|
||||
if ext := strings.ToLower(filepath.Ext(u.Path)); ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" {
|
||||
fileExt = ext
|
||||
}
|
||||
}
|
||||
|
||||
coverFile := filepath.Join(bookPath, "cover"+fileExt)
|
||||
out, err := os.Create(coverFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка создания файла: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
limitedReader := io.LimitReader(resp.Body, maxSize)
|
||||
|
||||
if _, err = io.Copy(out, limitedReader); err != nil {
|
||||
_ = os.Remove(coverFile)
|
||||
return fmt.Errorf("ошибка записи файла: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findMP3Files находит MP3 файлы в директории
|
||||
func (r *Repository) findMP3Files(dir string) ([]string, error) {
|
||||
var mp3Files []string
|
||||
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsDir() && strings.HasSuffix(strings.ToLower(file.Name()), ".mp3") {
|
||||
mp3Files = append(mp3Files, filepath.Join(dir, file.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
return mp3Files, nil
|
||||
}
|
||||
|
||||
// findCoverFile находит файл обложки
|
||||
func (r *Repository) findCoverFile(dir string) string {
|
||||
coverNames := []string{"cover.jpg", "cover.png", "cover.jpeg", "folder.jpg", "folder.png"}
|
||||
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsDir() {
|
||||
fileName := strings.ToLower(file.Name())
|
||||
for _, coverName := range coverNames {
|
||||
if fileName == coverName {
|
||||
return filepath.Join(dir, file.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// findDescriptionFile находит файл описания
|
||||
func (r *Repository) findDescriptionFile(dir string) string {
|
||||
descNames := []string{"description.txt", "readme.txt", "info.txt"}
|
||||
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsDir() {
|
||||
fileName := strings.ToLower(file.Name())
|
||||
for _, descName := range descNames {
|
||||
if fileName == descName {
|
||||
content, err := os.ReadFile(filepath.Join(dir, file.Name()))
|
||||
if err == nil {
|
||||
return string(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// RenameBookFolder переименовывает папку аудиокниги и возвращает новый путь
|
||||
func (r *Repository) RenameBookFolder(oldPath, newBaseName string) (string, error) {
|
||||
if oldPath == "" || newBaseName == "" {
|
||||
return oldPath, fmt.Errorf("некорректные параметры переименования")
|
||||
}
|
||||
parent := filepath.Dir(oldPath)
|
||||
// Заменим недопустимые для Windows символы
|
||||
safeName := strings.NewReplacer(
|
||||
"<", "(",
|
||||
">", ")",
|
||||
"|", "-",
|
||||
"?", "",
|
||||
"*", "",
|
||||
"\"", "'",
|
||||
":", " -",
|
||||
"/", "-",
|
||||
"\\", "-",
|
||||
).Replace(newBaseName)
|
||||
// Убираем хвосты RuTracker.org и повторяющиеся разделители в конце
|
||||
reSuffix := regexp.MustCompile(`(?i)\s*(?:\s*(?:\||::|:|—|-)\s*)+RuTracker\.?org.*$`)
|
||||
safeName = reSuffix.ReplaceAllString(safeName, "")
|
||||
safeName = strings.TrimSpace(safeName)
|
||||
safeName = regexp.MustCompile(`\s*(?:-\s*){2,}$`).ReplaceAllString(safeName, "") // " - -" в конце
|
||||
if safeName == "" {
|
||||
return oldPath, fmt.Errorf("пустое имя папки")
|
||||
}
|
||||
newPath := filepath.Join(parent, safeName)
|
||||
if strings.EqualFold(oldPath, newPath) {
|
||||
return oldPath, nil
|
||||
}
|
||||
if _, err := os.Stat(newPath); err == nil {
|
||||
return oldPath, fmt.Errorf("папка уже существует: %s", newPath)
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
return oldPath, fmt.Errorf("ошибка переименования: %w", err)
|
||||
}
|
||||
return newPath, nil
|
||||
}
|
||||
|
||||
// OrganizeBookFolder перемещает папку книги в organized/<Letter>/<Author>
|
||||
func (r *Repository) OrganizeBookFolder(bookPath, authorFullName, targetRoot string) (string, error) {
|
||||
if bookPath == "" || targetRoot == "" {
|
||||
return bookPath, fmt.Errorf("некорректные параметры перемещения")
|
||||
}
|
||||
// Определяем букву (первая буква фамилии/имени)
|
||||
author := strings.TrimSpace(authorFullName)
|
||||
if author == "" {
|
||||
author = "Unknown"
|
||||
}
|
||||
// Приводим к формату "Фамилия Имя" (если уже так — оставляем)
|
||||
author = regexp.MustCompile(`\s+`).ReplaceAllString(author, " ")
|
||||
letter := strings.ToUpper(string([]rune(author)[0]))
|
||||
// Безопасные имена
|
||||
safe := func(s string) string {
|
||||
return strings.NewReplacer(
|
||||
"<", "(", ">", ")", "|", "-", "?", "", "*", "", "\"", "'", ":", " -", "/", "-", "\\", "-",
|
||||
).Replace(strings.TrimSpace(s))
|
||||
}
|
||||
letterDir := filepath.Join(targetRoot, safe(letter))
|
||||
authorDir := filepath.Join(letterDir, safe(author))
|
||||
if err := os.MkdirAll(authorDir, 0755); err != nil {
|
||||
return bookPath, fmt.Errorf("ошибка создания каталогов: %w", err)
|
||||
}
|
||||
|
||||
newBookPath := filepath.Join(authorDir, filepath.Base(bookPath))
|
||||
if strings.EqualFold(bookPath, newBookPath) {
|
||||
return bookPath, nil
|
||||
}
|
||||
if _, err := os.Stat(newBookPath); err == nil {
|
||||
return bookPath, fmt.Errorf("папка уже существует: %s", newBookPath)
|
||||
}
|
||||
if err := os.Rename(bookPath, newBookPath); err != nil {
|
||||
return bookPath, fmt.Errorf("ошибка перемещения: %w", err)
|
||||
}
|
||||
return newBookPath, nil
|
||||
}
|
||||
53
internal/infrastructure/filesystem/repository_test.go
Normal file
53
internal/infrastructure/filesystem/repository_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindMP3Files(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
files := []string{"a.mp3", "B.MP3", "c.txt"}
|
||||
for _, f := range files {
|
||||
if err := os.WriteFile(filepath.Join(d, f), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
repo := NewRepository()
|
||||
got, err := repo.findMP3Files(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("ожидалось 2, получено %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameAndOrganize(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
book := filepath.Join(d, "Old")
|
||||
if err := os.MkdirAll(book, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepository()
|
||||
newPath, err := repo.RenameBookFolder(book, "Автор - Книга :: RuTracker.org")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(newPath) != "Автор - Книга" {
|
||||
t.Fatalf("имя папки: %s", filepath.Base(newPath))
|
||||
}
|
||||
|
||||
organized := filepath.Join(d, "organized")
|
||||
if err := os.MkdirAll(organized, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved, err := repo.OrganizeBookFolder(newPath, "Автор Фамилия", organized)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(moved); err != nil {
|
||||
t.Fatalf("папка не перемещена: %v", err)
|
||||
}
|
||||
}
|
||||
185
internal/infrastructure/logging/file_logger.go
Normal file
185
internal/infrastructure/logging/file_logger.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
)
|
||||
|
||||
// FileLogger реализация Logger для записи в файл
|
||||
type FileLogger struct {
|
||||
logFile *os.File
|
||||
logFileMu sync.Mutex
|
||||
logFileSize int64
|
||||
logLevel string
|
||||
fileName string
|
||||
maxSizeMB int
|
||||
logToFile bool
|
||||
}
|
||||
|
||||
// NewFileLogger создает новый файловый логгер
|
||||
func NewFileLogger(fileName string, logLevel string, maxSizeMB int, logToFile bool) (*FileLogger, error) {
|
||||
logger := &FileLogger{
|
||||
logLevel: logLevel,
|
||||
fileName: fileName,
|
||||
maxSizeMB: maxSizeMB,
|
||||
logToFile: logToFile,
|
||||
}
|
||||
|
||||
if logToFile {
|
||||
if err := logger.initLogFile(); err != nil {
|
||||
return nil, fmt.Errorf("ошибка инициализации лог файла: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return logger, nil
|
||||
}
|
||||
|
||||
// Info логирует информационное сообщение
|
||||
func (l *FileLogger) Info(format string, args ...interface{}) {
|
||||
if l.logLevel == "debug" || l.logLevel == "info" {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
l.writeToLogFile("INFO", message)
|
||||
}
|
||||
}
|
||||
|
||||
// Debug логирует отладочное сообщение
|
||||
func (l *FileLogger) Debug(format string, args ...interface{}) {
|
||||
if l.logLevel == "debug" {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
l.writeToLogFile("DEBUG", message)
|
||||
}
|
||||
}
|
||||
|
||||
// Warning логирует предупреждение
|
||||
func (l *FileLogger) Warning(format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
l.writeToLogFile("WARNING", message)
|
||||
}
|
||||
|
||||
// Error логирует ошибку
|
||||
func (l *FileLogger) Error(format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
l.writeToLogFile("ERROR", message)
|
||||
}
|
||||
|
||||
// Success логирует успешное выполнение
|
||||
func (l *FileLogger) Success(format string, args ...interface{}) {
|
||||
message := fmt.Sprintf(format, args...)
|
||||
l.writeToLogFile("SUCCESS", message)
|
||||
}
|
||||
|
||||
// Close закрывает лог файл
|
||||
func (l *FileLogger) Close() {
|
||||
if l.logFile != nil {
|
||||
l.logFile.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// initLogFile инициализирует лог файл
|
||||
func (l *FileLogger) initLogFile() error {
|
||||
if l.fileName == "" {
|
||||
l.fileName = "audio-catalyst.log"
|
||||
}
|
||||
|
||||
// Устанавливаем UTF-8 кодировку для Windows консоли
|
||||
if runtime.GOOS == "windows" {
|
||||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||||
setConsoleOutputCP := kernel32.NewProc("SetConsoleOutputCP")
|
||||
setConsoleOutputCP.Call(uintptr(65001)) // UTF-8
|
||||
}
|
||||
|
||||
var err error
|
||||
l.logFile, err = os.OpenFile(l.fileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("не удалось открыть лог файл: %w", err)
|
||||
}
|
||||
|
||||
// Получаем текущий размер файла
|
||||
if stat, err := l.logFile.Stat(); err == nil {
|
||||
l.logFileSize = stat.Size()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeToLogFile записывает сообщение в лог файл
|
||||
func (l *FileLogger) writeToLogFile(level, message string) {
|
||||
if !l.logToFile || l.logFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.logFileMu.Lock()
|
||||
defer l.logFileMu.Unlock()
|
||||
|
||||
timestamp := time.Now().Format("02.01.2006 15:04:05")
|
||||
raw := fmt.Sprintf("[%s] [%s] %s\n", timestamp, level, message)
|
||||
|
||||
var toWrite string
|
||||
if runtime.GOOS == "windows" {
|
||||
s := l.sanitizeForWindowsCP1251(raw)
|
||||
enc := charmap.Windows1251.NewEncoder()
|
||||
if encStr, err := enc.String(s); err == nil {
|
||||
toWrite = encStr
|
||||
} else {
|
||||
toWrite, _ = enc.String(l.sanitizeForWindowsCP1251(s))
|
||||
}
|
||||
} else {
|
||||
toWrite = raw
|
||||
}
|
||||
|
||||
// Проверяем размер файла и выполняем ротацию
|
||||
maxSize := int64(l.maxSizeMB) * 1024 * 1024
|
||||
if l.logFileSize+int64(len(toWrite)) > maxSize {
|
||||
l.logFile.Close()
|
||||
|
||||
var err error
|
||||
l.logFile, err = os.OpenFile(l.fileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
l.logFileSize = 0
|
||||
}
|
||||
|
||||
if n, err := l.logFile.WriteString(toWrite); err == nil {
|
||||
l.logFileSize += int64(n)
|
||||
l.logFile.Sync()
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeForWindowsCP1251 санитизирует строку для Windows-1251
|
||||
func (l *FileLogger) sanitizeForWindowsCP1251(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\n', '\r', '\t':
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
|
||||
// Кириллица
|
||||
if (r >= 0x0410 && r <= 0x044F) || r == 0x0401 || r == 0x0451 {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
|
||||
// ASCII печатаемые символы
|
||||
if r >= 0x20 && r <= 0x7E {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
|
||||
// Распространённые знаки пунктуации
|
||||
if (r >= 0x2013 && r <= 0x201E) || r == 0x00AB || r == 0x00BB {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
523
internal/infrastructure/rutracker/repository.go
Normal file
523
internal/infrastructure/rutracker/repository.go
Normal file
@@ -0,0 +1,523 @@
|
||||
package rutracker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"audio-catalyst/internal/domain/entities"
|
||||
"audio-catalyst/internal/domain/services"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"golang.org/x/text/encoding/charmap"
|
||||
)
|
||||
|
||||
// Repository реализация RuTrackerRepository
|
||||
type Repository struct {
|
||||
client *http.Client
|
||||
username string
|
||||
password string
|
||||
baseURL string
|
||||
metadataSvc *services.MetadataService
|
||||
}
|
||||
|
||||
// AuthError ошибка авторизации
|
||||
type AuthError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AuthError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// NewRepository создает новый репозиторий RuTracker
|
||||
func NewRepository(username, password string, proxyURL ...string) (*Repository, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("не удалось создать cookie jar: %v", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
if len(proxyURL) > 0 && proxyURL[0] != "" {
|
||||
proxyUrl, err := url.Parse(proxyURL[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("неверный URL прокси: %v", err)
|
||||
}
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyUrl),
|
||||
}
|
||||
client.Transport = transport
|
||||
}
|
||||
|
||||
return &Repository{
|
||||
client: client,
|
||||
username: username,
|
||||
password: password,
|
||||
baseURL: "https://rutracker.org",
|
||||
metadataSvc: services.NewMetadataService(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login выполняет авторизацию
|
||||
func (r *Repository) Login() error {
|
||||
loginURL := r.baseURL + "/forum/login.php"
|
||||
|
||||
formData := url.Values{
|
||||
"login_username": {r.username},
|
||||
"login_password": {r.password},
|
||||
"login": {"Вход"},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", loginURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return &AuthError{Message: fmt.Sprintf("ошибка создания запроса: %v", err)}
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return &AuthError{Message: fmt.Sprintf("ошибка выполнения запроса: %v", err)}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &AuthError{Message: fmt.Sprintf("статус-код авторизации: %d", resp.StatusCode)}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return &AuthError{Message: fmt.Sprintf("ошибка чтения ответа: %v", err)}
|
||||
}
|
||||
|
||||
responseText := string(body)
|
||||
if strings.Contains(responseText, "cap_sid") {
|
||||
return &AuthError{Message: "найдена капча при авторизации!"}
|
||||
}
|
||||
|
||||
loginURL_parsed, _ := url.Parse(loginURL)
|
||||
cookies := r.client.Jar.Cookies(loginURL_parsed)
|
||||
if len(cookies) == 0 {
|
||||
return &AuthError{Message: "не удалось выполнить авторизацию - cookies не найдены"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search выполняет поиск торрентов
|
||||
func (r *Repository) Search(query string, page int) ([]entities.Torrent, error) {
|
||||
searchURL := r.baseURL + "/forum/tracker.php"
|
||||
|
||||
params := url.Values{
|
||||
"nm": {query},
|
||||
"start": {fmt.Sprintf("%d", (page-1)*50)},
|
||||
}
|
||||
|
||||
fullURL := searchURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка создания запроса: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка выполнения запроса: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("статус-код поиска: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка чтения ответа: %v", err)
|
||||
}
|
||||
|
||||
responseText := string(body)
|
||||
if strings.Contains(responseText, "top-login-box") {
|
||||
return nil, fmt.Errorf("необходима повторная авторизация")
|
||||
}
|
||||
|
||||
return r.parseSearchResults(responseText)
|
||||
}
|
||||
|
||||
// GetTopicMetadata получает метаданные со страницы темы
|
||||
func (r *Repository) GetTopicMetadata(topicID string) (*entities.RuTrackerResult, error) {
|
||||
topicURL := r.baseURL + "/forum/viewtopic.php"
|
||||
|
||||
params := url.Values{
|
||||
"t": {topicID},
|
||||
}
|
||||
|
||||
fullURL := topicURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка создания запроса: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка выполнения запроса: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("статус-код темы: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка чтения ответа: %v", err)
|
||||
}
|
||||
|
||||
responseText := string(body)
|
||||
if strings.Contains(responseText, "top-login-box") {
|
||||
return nil, fmt.Errorf("необходима повторная авторизация")
|
||||
}
|
||||
|
||||
// Преобразуем в торрент для использования в метаданных
|
||||
torrent := entities.Torrent{ID: topicID}
|
||||
|
||||
// Парсим метаданные
|
||||
metadata, err := r.metadataSvc.ParseTopicMetadata(responseText, torrent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка парсинга метаданных: %w", err)
|
||||
}
|
||||
|
||||
// Преобразуем в RuTrackerResult
|
||||
result := &entities.RuTrackerResult{
|
||||
Title: metadata.Title,
|
||||
Subtitle: metadata.Subtitle,
|
||||
Authors: metadata.Authors,
|
||||
Narrators: metadata.Narrators,
|
||||
Series: metadata.Series,
|
||||
Genres: metadata.Genres,
|
||||
Description: metadata.Description,
|
||||
}
|
||||
|
||||
if metadata.PublishedYear != nil {
|
||||
result.Year = metadata.PublishedYear
|
||||
}
|
||||
|
||||
if metadata.Publisher != nil {
|
||||
result.Publisher = metadata.Publisher
|
||||
}
|
||||
|
||||
// Извлекаем URL обложки из HTML
|
||||
if cover := r.extractCoverURL(responseText); cover != "" {
|
||||
result.CoverURL = cover
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// extractCoverURL пытается найти URL обложки на странице темы (DOM-парсинг + fallback)
|
||||
func (r *Repository) extractCoverURL(htmlStr string) string {
|
||||
if u := r.extractCoverURLDOM(htmlStr); u != "" {
|
||||
return u
|
||||
}
|
||||
return r.extractCoverURLRegex(htmlStr)
|
||||
}
|
||||
|
||||
// extractCoverURLDOM — более точный выбор картинки через goquery
|
||||
func (r *Repository) extractCoverURLDOM(htmlStr string) string {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlStr))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
post := doc.Find("div.post_body").First()
|
||||
if post.Length() == 0 {
|
||||
post = doc.Selection // fallback ко всей странице
|
||||
}
|
||||
|
||||
// 1) Ссылки с вложенными img (часто ведут на полноразмер)
|
||||
found := ""
|
||||
post.Find("a:has(img)").EachWithBreak(func(_ int, a *goquery.Selection) bool {
|
||||
href, _ := a.Attr("href")
|
||||
if r.isImageLink(href) {
|
||||
found = r.normalizeURL(href)
|
||||
return false
|
||||
}
|
||||
img := a.Find("img").First()
|
||||
if u := r.bestImgSrc(img); u != "" {
|
||||
found = r.normalizeURL(u)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if found != "" && !r.isJunkImage(found) {
|
||||
return found
|
||||
}
|
||||
|
||||
// 2) Специальный случай RuTracker: <var class="postImg" title="..."> (содержит прямую ссылку)
|
||||
post.Find("var.postImg").EachWithBreak(func(_ int, v *goquery.Selection) bool {
|
||||
if u, ok := v.Attr("title"); ok && u != "" {
|
||||
found = r.normalizeURL(u)
|
||||
return false
|
||||
}
|
||||
text := strings.TrimSpace(v.Text())
|
||||
if text != "" {
|
||||
found = r.normalizeURL(text)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if found != "" && !r.isJunkImage(found) && r.isImageLink(found) {
|
||||
return found
|
||||
}
|
||||
|
||||
// 3) Любые img внутри поста — выбрать лучший src/data-src/srcset/src
|
||||
post.Find("img").EachWithBreak(func(_ int, img *goquery.Selection) bool {
|
||||
u := r.bestImgSrc(img)
|
||||
if u != "" {
|
||||
found = r.normalizeURL(u)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if found != "" && !r.isJunkImage(found) {
|
||||
return found
|
||||
}
|
||||
|
||||
// 4) og:image
|
||||
if og, exists := doc.Find("meta[property='og:image']").Attr("content"); exists {
|
||||
u := r.normalizeURL(og)
|
||||
if u != "" && !r.isJunkImage(u) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// bestImgSrc извлекает наилучший URL изображения из тега img
|
||||
func (r *Repository) bestImgSrc(img *goquery.Selection) string {
|
||||
if u, ok := img.Attr("data-original"); ok && u != "" {
|
||||
return u
|
||||
}
|
||||
if u, ok := img.Attr("data-src"); ok && u != "" {
|
||||
return u
|
||||
}
|
||||
// srcset — берём последнюю (как правило, наибольшую)
|
||||
if ss, ok := img.Attr("srcset"); ok && ss != "" {
|
||||
parts := strings.Split(ss, ",")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
p := strings.TrimSpace(parts[i])
|
||||
if m := regexp.MustCompile(`^([^\s]+)`).FindStringSubmatch(p); len(m) >= 2 {
|
||||
return m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
if u, ok := img.Attr("src"); ok && u != "" {
|
||||
return u
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractCoverURLRegex — фолбэк (regex)
|
||||
func (r *Repository) extractCoverURLRegex(htmlStr string) string {
|
||||
h := html.UnescapeString(htmlStr)
|
||||
// var.postImg title="..."
|
||||
if m := regexp.MustCompile(`(?is)<var[^>]+class=["'][^"']*postImg[^"']*["'][^>]+title=["']([^"']+)["'][^>]*>`).FindStringSubmatch(h); len(m) >= 2 {
|
||||
u := r.normalizeURL(m[1])
|
||||
if u != "" && !r.isJunkImage(u) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
// var.postImg>URL
|
||||
if m := regexp.MustCompile(`(?is)<var[^>]+class=["'][^"']*postImg[^"']*["'][^>]*>([^<\s][^<]+)` + "</var>").FindStringSubmatch(h); len(m) >= 2 {
|
||||
u := r.normalizeURL(strings.TrimSpace(m[1]))
|
||||
if u != "" && !r.isJunkImage(u) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
// a[href$=.jpg|.png|.gif]
|
||||
if m := regexp.MustCompile(`(?is)<a[^>]+href=["']([^"']+?\.(?:jpg|jpeg|png|gif))(?:\?[^"']*)?["'][^>]*>`).FindStringSubmatch(h); len(m) >= 2 {
|
||||
u := r.normalizeURL(m[1])
|
||||
if u != "" && !r.isJunkImage(u) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
// img data-original|data-src|src
|
||||
if m := regexp.MustCompile(`(?is)<img[^>]+(?:data-original|data-src|src)=["']([^"']+?\.(?:jpg|jpeg|png|gif))(?:\?[^"']*)?["'][^>]*>`).FindStringSubmatch(h); len(m) >= 2 {
|
||||
u := r.normalizeURL(m[1])
|
||||
if u != "" && !r.isJunkImage(u) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
// og:image
|
||||
if m := regexp.MustCompile(`(?i)<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']`).FindStringSubmatch(h); len(m) >= 2 {
|
||||
u := r.normalizeURL(m[1])
|
||||
if u != "" && !r.isJunkImage(u) {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isImageLink проверяет, что ссылка указывает на изображение или вложение с картинкой
|
||||
func (r *Repository) isImageLink(href string) bool {
|
||||
if href == "" {
|
||||
return false
|
||||
}
|
||||
lu := strings.ToLower(href)
|
||||
if strings.HasSuffix(lu, ".jpg") || strings.HasSuffix(lu, ".jpeg") || strings.HasSuffix(lu, ".png") || strings.HasSuffix(lu, ".gif") {
|
||||
return true
|
||||
}
|
||||
// типичные вложения RuTracker
|
||||
if strings.Contains(lu, "/forum/dl.php?i=") || strings.Contains(lu, "/forum/download/file.php") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DownloadTorrent скачивает торрент файл
|
||||
func (r *Repository) DownloadTorrent(topicID string) ([]byte, error) {
|
||||
downloadURL := r.baseURL + "/forum/dl.php"
|
||||
|
||||
params := url.Values{
|
||||
"t": {topicID},
|
||||
}
|
||||
|
||||
fullURL := downloadURL + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка создания запроса: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка выполнения запроса: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("статус-код загрузки: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка чтения файла: %v", err)
|
||||
}
|
||||
|
||||
if bytes.Contains(content, []byte("Error")) || bytes.Contains(content, []byte("<html")) {
|
||||
return nil, fmt.Errorf("файл с ID %s не найден", topicID)
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// Close закрывает соединение
|
||||
func (r *Repository) Close() {
|
||||
if transport, ok := r.client.Transport.(*http.Transport); ok {
|
||||
transport.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
|
||||
// parseSearchResults парсит результаты поиска
|
||||
func (r *Repository) parseSearchResults(htmlContent string) ([]entities.Torrent, error) {
|
||||
var torrents []entities.Torrent
|
||||
|
||||
decoder := charmap.Windows1251.NewDecoder()
|
||||
decodedContent, err := decoder.String(htmlContent)
|
||||
if err != nil {
|
||||
decodedContent = htmlContent
|
||||
}
|
||||
|
||||
// Парсинг ID торрентов
|
||||
idPattern := regexp.MustCompile(`dl\.php\?t=(\d+)`)
|
||||
idMatches := idPattern.FindAllStringSubmatch(decodedContent, -1)
|
||||
|
||||
// Парсинг названий
|
||||
titlePattern := regexp.MustCompile(`<a[^>]*href="[^"]*viewtopic\.php\?t=(\d+)"[^>]*>(.*?)</a>`)
|
||||
titleMatches := titlePattern.FindAllStringSubmatch(decodedContent, -1)
|
||||
|
||||
// Создаем карту торрентов
|
||||
torrentMap := make(map[string]*entities.Torrent)
|
||||
|
||||
for _, match := range idMatches {
|
||||
if len(match) >= 2 {
|
||||
id := match[1]
|
||||
if torrentMap[id] == nil {
|
||||
torrentMap[id] = &entities.Torrent{
|
||||
ID: id,
|
||||
Title: "Unknown",
|
||||
Size: "Unknown",
|
||||
Seeds: "0",
|
||||
Leeches: "0",
|
||||
TopicURL: fmt.Sprintf("https://rutracker.org/forum/viewtopic.php?t=%s", id),
|
||||
DownloadURL: fmt.Sprintf("https://rutracker.org/forum/dl.php?t=%s", id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, match := range titleMatches {
|
||||
if len(match) >= 3 {
|
||||
id := match[1]
|
||||
title := match[2]
|
||||
title = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(title, "")
|
||||
title = strings.TrimSpace(title)
|
||||
|
||||
if torrentMap[id] != nil {
|
||||
torrentMap[id].Title = title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, torrent := range torrentMap {
|
||||
torrents = append(torrents, *torrent)
|
||||
}
|
||||
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
// isJunkImage отфильтровывает заведомо нерелевантные картинки (лого, фавиконки, пиксели)
|
||||
func (r *Repository) isJunkImage(u string) bool {
|
||||
lu := strings.ToLower(u)
|
||||
deny := []string{
|
||||
"logo", "favicon", "sprite", "blank", "pixel", "counter", "/images/", "/img/flags/", "/smiles/", "1x1", "spacer",
|
||||
"/forum/images/", "static.rutracker", "/styles/", "/css/", "/js/",
|
||||
}
|
||||
for _, d := range deny {
|
||||
if strings.Contains(lu, d) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *Repository) normalizeURL(u string) string {
|
||||
u = strings.TrimSpace(html.UnescapeString(u))
|
||||
if strings.HasPrefix(u, "//") {
|
||||
return "https:" + u
|
||||
}
|
||||
if strings.HasPrefix(u, "/") {
|
||||
return r.baseURL + u
|
||||
}
|
||||
if _, err := url.Parse(u); err == nil {
|
||||
return u
|
||||
}
|
||||
return ""
|
||||
}
|
||||
20
internal/infrastructure/rutracker/repository_test.go
Normal file
20
internal/infrastructure/rutracker/repository_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package rutracker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeURL(t *testing.T) {
|
||||
r := &Repository{baseURL: "https://rutracker.org"}
|
||||
cases := map[string]string{
|
||||
"//cdn/img.jpg": "https://cdn/img.jpg",
|
||||
"/forum/dl.php?i=1": "https://rutracker.org/forum/dl.php?i=1",
|
||||
"https://x/y.jpg": "https://x/y.jpg",
|
||||
}
|
||||
for in, exp := range cases {
|
||||
got := r.normalizeURL(in)
|
||||
if got != exp {
|
||||
t.Errorf("%q => %q, ожидалось %q", in, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user