1
1

Добавить тесты репозитория файловой системы и реализовать функциональность журналирования файлов.

- Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы.
- Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов.
- Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов.
- Добавить тесты для нормализации URL в репозиторий RuTracker.
- Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер.
- Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов.
This commit is contained in:
Dmitriy Fofanov
2025-09-29 20:40:05 +03:00
parent 49bea780aa
commit 72a66f1664
32 changed files with 4073 additions and 22 deletions

View File

@@ -0,0 +1,209 @@
package usecases
import (
"fmt"
"path/filepath"
"audio-catalyst/internal/domain/entities"
"audio-catalyst/internal/domain/repositories"
"audio-catalyst/internal/domain/services"
)
// ProcessAudioBooksUseCase обрабатывает аудиокниги
type ProcessAudioBooksUseCase struct {
audioBookRepo repositories.AudioBookRepository
rutrackerRepo repositories.RuTrackerRepository
logger repositories.Logger
audioBookSvc *services.AudioBookService
metadataSvc *services.MetadataService
}
// NewProcessAudioBooksUseCase создает новый use case
func NewProcessAudioBooksUseCase(
audioBookRepo repositories.AudioBookRepository,
rutrackerRepo repositories.RuTrackerRepository,
logger repositories.Logger,
) *ProcessAudioBooksUseCase {
return &ProcessAudioBooksUseCase{
audioBookRepo: audioBookRepo,
rutrackerRepo: rutrackerRepo,
logger: logger,
audioBookSvc: services.NewAudioBookService(),
metadataSvc: services.NewMetadataService(),
}
}
// Execute выполняет обработку аудиокниг
func (uc *ProcessAudioBooksUseCase) Execute(config *entities.Config) error {
uc.logger.Info("🚀 Начало процесса обработки аудиокниг")
uc.logger.Info("📁 Исходная директория: %s", config.Scanner.SourceDirectory)
// Сканирование папок с аудиокнигами
audioBooks, err := uc.audioBookRepo.ScanDirectory(config.Scanner.SourceDirectory)
if err != nil {
uc.logger.Error("❌ Критическая ошибка сканирования: %v", err)
return fmt.Errorf("ошибка сканирования: %w", err)
}
uc.logger.Info("📊 Найдено %d аудиокниг", len(audioBooks))
if len(audioBooks) == 0 {
uc.logger.Warning("⚠️ Аудиокниги не найдены")
return nil
}
// Авторизация в RuTracker
if err := uc.rutrackerRepo.Login(); err != nil {
uc.logger.Error("❌ Ошибка авторизации в RuTracker: %v", err)
return fmt.Errorf("ошибка авторизации: %w", err)
}
uc.logger.Success("✅ Авторизация в RuTracker успешна")
// Обработка каждой аудиокниги
for i, book := range audioBooks {
uc.logger.Info("📋 Обработка книги %d/%d: \"%s\"", i+1, len(audioBooks), book.Title)
if err := uc.processAudioBook(book); err != nil {
uc.logger.Error("❌ Ошибка обработки книги \"%s\": %v", book.Title, err)
continue
}
uc.logger.Success("✅ Книга \"%s\" обработана", book.Title)
}
uc.logger.Success("🎉 Обработка завершена успешно!")
return nil
}
// processAudioBook обрабатывает одну аудиокнигу
func (uc *ProcessAudioBooksUseCase) processAudioBook(book entities.AudioBook) error {
// Поиск торрентов
torrents, err := uc.rutrackerRepo.Search(book.Title, 1)
if err != nil {
uc.logger.Warning("⚠️ Ошибка поиска торрентов для \"%s\": %v", book.Title, err)
return nil // переходим к следующей книге
}
if len(torrents) == 0 {
uc.logger.Warning("⚠️ Не найдено на RuTracker: \"%s\" — пропускаем", book.Title)
return nil // переходим к следующей книге
}
// Используем первый торрент
bestTorrent := torrents[0]
uc.logger.Info("📦 Выбран торрент: %s", bestTorrent.Title)
// Получаем метаданные
rutrackerResult, err := uc.rutrackerRepo.GetTopicMetadata(bestTorrent.ID)
if err != nil {
uc.logger.Warning("⚠️ Ошибка получения метаданных: %v", err)
return nil // переходим к следующей книге
}
if rutrackerResult.CoverURL != "" {
uc.logger.Info("🖼️ Найдена обложка: %s", rutrackerResult.CoverURL)
} else {
uc.logger.Debug("Обложка на странице не найдена")
}
// Создаем метаданные
metadata := uc.createMetadataFromRuTracker(book, rutrackerResult)
// Переименуем папку книги под Subtitle, если он есть
bookPath := book.Path
if metadata.Subtitle != "" && metadata.Subtitle != filepath.Base(book.Path) {
if newPath, err := uc.audioBookRepo.RenameBookFolder(book.Path, metadata.Subtitle); err != nil {
uc.logger.Warning("⚠️ Не удалось переименовать папку: %v", err)
} else {
uc.logger.Success("📁 Папка переименована: %s", filepath.Base(newPath))
bookPath = newPath
}
}
// Если есть CoverURL — пробуем скачать обложку
if rutrackerResult.CoverURL != "" {
if err := uc.audioBookRepo.DownloadCover(rutrackerResult.CoverURL, bookPath); err != nil {
uc.logger.Warning("⚠️ Не удалось загрузить обложку: %v", err)
} else {
uc.logger.Success("🖼️ Обложка загружена")
}
}
// Сохраняем метаданные
if err := uc.audioBookRepo.SaveMetadata(bookPath, metadata); err != nil {
return fmt.Errorf("ошибка сохранения метаданных: %w", err)
}
uc.logger.Success("💾 Метаданные сохранены")
// Организация в библиотеке organized
if len(metadata.Authors) > 0 {
author := metadata.Authors[0]
targetRoot := "./organized"
if newPath, err := uc.audioBookRepo.OrganizeBookFolder(bookPath, author, targetRoot); err != nil {
uc.logger.Warning("⚠️ Не удалось организовать папку: %v", err)
} else {
uc.logger.Success("📚 Папка перемещена в библиотеку: %s", newPath)
}
}
return nil
}
// createBasicMetadata создает базовые метаданные
func (uc *ProcessAudioBooksUseCase) createBasicMetadata(book entities.AudioBook) error {
metadata := &entities.AudioBookMetadata{
Tags: []string{},
Chapters: uc.audioBookSvc.CreateChapters(book.MP3Files),
Title: book.Title,
Subtitle: book.Title,
Authors: []string{},
Narrators: []string{},
Series: []string{},
Genres: []string{},
Description: book.Description,
Language: "ru",
Explicit: false,
Abridged: false,
}
return uc.audioBookRepo.SaveMetadata(book.Path, metadata)
}
// createMetadataFromRuTracker создает метаданные на основе данных RuTracker
func (uc *ProcessAudioBooksUseCase) createMetadataFromRuTracker(book entities.AudioBook, rutrackerResult *entities.RuTrackerResult) *entities.AudioBookMetadata {
metadata := &entities.AudioBookMetadata{
Tags: []string{},
Chapters: uc.audioBookSvc.CreateChapters(book.MP3Files),
Title: rutrackerResult.Title,
Subtitle: rutrackerResult.Subtitle,
Authors: rutrackerResult.Authors,
Narrators: rutrackerResult.Narrators,
Series: rutrackerResult.Series,
Genres: rutrackerResult.Genres,
Description: rutrackerResult.Description,
Language: "ru",
Explicit: false,
Abridged: false,
}
if rutrackerResult.Year != nil {
metadata.PublishedYear = rutrackerResult.Year
}
if rutrackerResult.Publisher != nil {
metadata.Publisher = rutrackerResult.Publisher
}
if metadata.Title == "" {
metadata.Title = book.Title
}
if metadata.Subtitle == "" {
metadata.Subtitle = metadata.Title
}
if metadata.Description == "" && book.Description != "" {
metadata.Description = book.Description
}
return metadata
}

View File

@@ -0,0 +1,70 @@
package usecases
import (
"os"
"path/filepath"
"testing"
"audio-catalyst/internal/domain/entities"
)
type memRepo struct{ books []entities.AudioBook }
func (m *memRepo) ScanDirectory(root string) ([]entities.AudioBook, error) { return m.books, nil }
func (m *memRepo) SaveMetadata(p string, _ *entities.AudioBookMetadata) error { return nil }
func (m *memRepo) DownloadCover(_, _ string) error { return nil }
func (m *memRepo) RenameBookFolder(old, new string) (string, error) {
return filepath.Join(filepath.Dir(old), new), nil
}
func (m *memRepo) OrganizeBookFolder(p, _, target string) (string, error) {
return filepath.Join(target, filepath.Base(p)), nil
}
type fakeRT struct{}
func (f *fakeRT) Login() error { return nil }
func (f *fakeRT) Search(q string, _ int) ([]entities.Torrent, error) {
if q == "skip" {
return []entities.Torrent{}, nil
}
return []entities.Torrent{{ID: "1", Title: "hit"}}, nil
}
func (f *fakeRT) GetTopicMetadata(_ string) (*entities.RuTrackerResult, error) {
return &entities.RuTrackerResult{Title: "Книга", Subtitle: "Автор - Книга", Authors: []string{"Автор Фамилия"}}, nil
}
func (f *fakeRT) DownloadTorrent(string) ([]byte, error) { return nil, nil }
func (f *fakeRT) Close() {}
type nopLogger struct{}
func (nopLogger) Info(string, ...interface{}) {}
func (nopLogger) Debug(string, ...interface{}) {}
func (nopLogger) Warning(string, ...interface{}) {}
func (nopLogger) Error(string, ...interface{}) {}
func (nopLogger) Success(string, ...interface{}) {}
func TestProcessSkipsWhenNotFound(t *testing.T) {
tmp := t.TempDir()
os.MkdirAll(filepath.Join(tmp, "skip"), 0755)
repo := &memRepo{books: []entities.AudioBook{{Path: filepath.Join(tmp, "skip"), Title: "skip", MP3Files: []string{}}}}
uc := NewProcessAudioBooksUseCase(repo, &fakeRT{}, nopLogger{})
cfg := &entities.Config{}
cfg.Scanner.SourceDirectory = tmp
if err := uc.Execute(cfg); err != nil {
t.Fatal(err)
}
}
func TestProcessHappyPath(t *testing.T) {
tmp := t.TempDir()
bookDir := filepath.Join(tmp, "book")
os.MkdirAll(bookDir, 0755)
os.WriteFile(filepath.Join(bookDir, "01.mp3"), []byte("x"), 0644)
repo := &memRepo{books: []entities.AudioBook{{Path: bookDir, Title: "book", MP3Files: []string{filepath.Join(bookDir, "01.mp3")}}}}
uc := NewProcessAudioBooksUseCase(repo, &fakeRT{}, nopLogger{})
cfg := &entities.Config{}
cfg.Scanner.SourceDirectory = tmp
if err := uc.Execute(cfg); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,41 @@
package entities
// AudioBook представляет аудиокнигу
type AudioBook struct {
Path string
Title string
MP3Files []string
CoverFile string
Description string
Metadata *AudioBookMetadata
}
// AudioBookMetadata метаданные аудиокниги
type AudioBookMetadata struct {
Tags []string `json:"tags"`
Chapters []Chapter `json:"chapters"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Authors []string `json:"authors"`
Narrators []string `json:"narrators"`
Series []string `json:"series"`
Genres []string `json:"genres"`
PublishedYear *int `json:"publishedYear"`
PublishedDate *string `json:"publishedDate"`
Publisher *string `json:"publisher"`
Description string `json:"description"`
ISBN *string `json:"isbn"`
ASIN *string `json:"asin"`
Language string `json:"language"`
Explicit bool `json:"explicit"`
Abridged bool `json:"abridged"`
}
// Chapter представляет главу аудиокниги
type Chapter struct {
ID int `json:"id"`
Start float64 `json:"start"`
End float64 `json:"end"`
Title string `json:"title"`
Duration int `json:"duration"` // в секундах
}

View File

@@ -0,0 +1,40 @@
package entities
// Config главная конфигурация приложения
type Config struct {
Scanner ScannerConfig `yaml:"scanner"`
RuTracker RuTrackerConfig `yaml:"rutracker"`
Processing ProcessingConfig `yaml:"processing"`
Output OutputConfig `yaml:"output"`
}
// ScannerConfig настройки сканирования файлов
type ScannerConfig struct {
SourceDirectory string `yaml:"source_directory"`
TargetDirectory string `yaml:"target_directory"`
}
// RuTrackerConfig настройки для работы с RuTracker
type RuTrackerConfig struct {
BaseURL string `yaml:"base_url"`
UserAgent string `yaml:"user_agent"`
RequestDelay int `yaml:"request_delay"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
// ProcessingConfig настройки обработки
type ProcessingConfig struct {
ParallelWorkers int `yaml:"parallel_workers"`
TimeoutSeconds int `yaml:"timeout_seconds"`
RetryAttempts int `yaml:"retry_attempts"`
}
// OutputConfig настройки вывода и логирования
type OutputConfig struct {
LogLevel string `yaml:"log_level"`
ProgressBar bool `yaml:"progress_bar"`
LogToFile bool `yaml:"log_to_file"`
LogFileName string `yaml:"log_file_name"`
LogMaxSizeMB int `yaml:"log_max_size_mb"`
}

View File

@@ -0,0 +1,19 @@
package entities
// ProcessingStatus статус обработки
type ProcessingStatus struct {
Current int
Total int
Status string
Error error
}
// UIScreen представляет экраны приложения
type UIScreen int
const (
ScreenMainMenu UIScreen = iota
ScreenProcessing
ScreenSettings
ScreenResults
)

View File

@@ -0,0 +1,27 @@
package entities
// Torrent информация о торренте из результатов поиска
type Torrent struct {
ID string
Title string
Size string
Seeds string
Leeches string
Downloads string
TopicURL string
DownloadURL string
}
// RuTrackerResult результат поиска на RuTracker
type RuTrackerResult struct {
Title string
Subtitle string
Authors []string
Narrators []string
Series []string
Year *int
Publisher *string
Description string
Genres []string
CoverURL string
}

View File

@@ -0,0 +1,21 @@
package repositories
import "audio-catalyst/internal/domain/entities"
// AudioBookRepository интерфейс для работы с аудиокнигами
type AudioBookRepository interface {
// ScanDirectory сканирует директорию на наличие аудиокниг
ScanDirectory(rootDir string) ([]entities.AudioBook, error)
// SaveMetadata сохраняет метаданные аудиокниги
SaveMetadata(bookPath string, metadata *entities.AudioBookMetadata) error
// DownloadCover загружает обложку по URL
DownloadCover(coverURL, bookPath string) error
// RenameBookFolder переименовывает папку аудиокниги
RenameBookFolder(oldPath, newBaseName string) (string, error)
// OrganizeBookFolder перемещает папку книги в библиотеку organized: <root>/<Letter>/<Author>/<Book>
OrganizeBookFolder(bookPath, authorFullName, targetRoot string) (string, error)
}

View File

@@ -0,0 +1,12 @@
package repositories
import "audio-catalyst/internal/domain/entities"
// ConfigRepository интерфейс для работы с конфигурацией
type ConfigRepository interface {
// Load загружает конфигурацию из файла
Load(filename string) (*entities.Config, error)
// Save сохраняет конфигурацию в файл
Save(filename string, config *entities.Config) error
}

View File

@@ -0,0 +1,10 @@
package repositories
// Logger интерфейс для логирования
type Logger interface {
Info(format string, args ...interface{})
Debug(format string, args ...interface{})
Warning(format string, args ...interface{})
Error(format string, args ...interface{})
Success(format string, args ...interface{})
}

View File

@@ -0,0 +1,21 @@
package repositories
import "audio-catalyst/internal/domain/entities"
// RuTrackerRepository интерфейс для работы с RuTracker
type RuTrackerRepository interface {
// Login выполняет авторизацию
Login() error
// Search выполняет поиск торрентов
Search(query string, page int) ([]entities.Torrent, error)
// GetTopicMetadata получает метаданные со страницы темы
GetTopicMetadata(topicID string) (*entities.RuTrackerResult, error)
// DownloadTorrent скачивает торрент файл
DownloadTorrent(topicID string) ([]byte, error)
// Close закрывает соединение
Close()
}

View File

@@ -0,0 +1,132 @@
package services
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"audio-catalyst/internal/domain/entities"
)
// AudioBookService сервис для работы с аудиокнигами
type AudioBookService struct{}
// NewAudioBookService создает новый сервис аудиокниг
func NewAudioBookService() *AudioBookService {
return &AudioBookService{}
}
// CreateChapters создает главы из MP3 файлов
func (s *AudioBookService) CreateChapters(mp3Files []string) []entities.Chapter {
// Гарантируем пустой слайс, а не nil (в JSON будет [] вместо null)
chapters := make([]entities.Chapter, 0, len(mp3Files))
// Сортировка файлов по имени
sortedFiles := make([]string, len(mp3Files))
copy(sortedFiles, mp3Files)
sort.Strings(sortedFiles)
currentStart := 0.0
for i, file := range sortedFiles {
fileName := filepath.Base(file)
baseName := strings.TrimSuffix(fileName, ".mp3")
chapterTitle := s.extractChapterTitle(baseName)
// Оцениваем длительность файла в секундах
durSec := s.estimateDuration(file)
if durSec < 0 {
durSec = 0
}
chapter := entities.Chapter{
ID: i,
Start: currentStart,
End: currentStart + durSec,
Title: chapterTitle,
Duration: int(durSec),
}
currentStart += durSec
chapters = append(chapters, chapter)
}
return chapters
}
// extractChapterTitle извлекает название главы из имени файла
func (s *AudioBookService) extractChapterTitle(fileName string) string {
name := strings.TrimSuffix(fileName, ".mp3")
patterns := []string{
`^(\d{1,3})[\s\-_.]*(.*)$`,
`^[Cc]hapter[\s_]*(\d+)(.*)$`,
`^[Гг]лава[\s_]*(\d+)(.*)$`,
`^[Чч]асть[\s_]*(\d+)(.*)$`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
if matches := re.FindStringSubmatch(name); len(matches) >= 2 {
number := matches[1]
title := ""
if len(matches) >= 3 {
title = strings.TrimSpace(matches[2])
title = strings.TrimLeft(title, "-_. ")
}
if title != "" {
return fmt.Sprintf("%s - %s", number, title)
}
return number
}
}
return name
}
// estimateDuration оценивает продолжительность MP3 файла (в секундах)
// Приближение: 1 MB ≈ 0.86 минуты (≈51.6 секунды) при средних битрейтах
func (s *AudioBookService) estimateDuration(filePath string) float64 {
info, err := os.Stat(filePath)
if err != nil {
return 0
}
// Размер файла в MB
sizeMB := float64(info.Size()) / (1024.0 * 1024.0)
// Примерная продолжительность в минутах
estimatedMinutes := sizeMB * 0.86
return estimatedMinutes * 60.0
}
// CleanTitle очищает название книги от технической информации
func (s *AudioBookService) CleanTitle(title string) string {
// Удаляем информацию в квадратных скобках в конце
title = regexp.MustCompile(`\s*\[.*?\]\s*$`).ReplaceAllString(title, "")
// Удаляем информацию в круглых скобках в конце
title = regexp.MustCompile(`\s*\(.*?\)\s*$`).ReplaceAllString(title, "")
// Удаляем технические обозначения
techPatterns := []string{
`\s*MP3\s*$`,
`\s*FLAC\s*$`,
`\s*\d+\s*kbps\s*$`,
`\s*\d+\s*кбит/с\s*$`,
`\s*VBR\s*$`,
`\s*CBR\s*$`,
`\s*\d{4}\s*$`,
}
for _, pattern := range techPatterns {
title = regexp.MustCompile(`(?i)`+pattern).ReplaceAllString(title, "")
}
// Убираем лишние пробелы
title = strings.TrimSpace(title)
title = regexp.MustCompile(`\s+`).ReplaceAllString(title, " ")
return title
}

View File

@@ -0,0 +1,66 @@
package services
import (
"os"
"path/filepath"
"testing"
)
func TestCreateChaptersAndDurations(t *testing.T) {
dir := t.TempDir()
files := []string{"02.mp3", "01.mp3"}
var paths []string
// создаём разные размеры для разной длительности
for i, f := range files {
p := filepath.Join(dir, f)
data := make([]byte, (i+1)*1024*1024) // 1MB, 2MB
if err := os.WriteFile(p, data, 0644); err != nil {
t.Fatalf("write: %v", err)
}
paths = append(paths, p)
}
svc := NewAudioBookService()
chapters := svc.CreateChapters(paths)
if len(chapters) != 2 {
t.Fatalf("ожидалось 2 главы, получено %d", len(chapters))
}
// порядок по имени: 01.mp3, 02.mp3
if chapters[0].Title != "01" || chapters[1].Title != "02" {
t.Errorf("неверные названия глав: %+v", chapters)
}
if !(chapters[0].Duration > 40 && chapters[1].Duration > chapters[0].Duration) {
t.Errorf("неверные длительности: %+v", chapters)
}
if !(chapters[0].Start == 0 && chapters[0].End < chapters[1].Start) {
t.Errorf("неверные интервалы: %+v", chapters)
}
}
func TestExtractChapterTitlePatterns(t *testing.T) {
svc := NewAudioBookService()
cases := map[string]string{
"01 - Введение": "01 - Введение",
"001_Первая_глава": "001 - Первая_глава",
"Chapter 05 - Название": "05 - Название",
"Глава 3 - Важная информация": "3 - Важная информация",
"Часть 10": "10",
"простоеазвание": "простоеазвание",
"15": "15",
}
for in, exp := range cases {
got := svc.extractChapterTitle(in)
if got != exp {
t.Errorf("%q => %q, ожидалось %q", in, got, exp)
}
}
}
func TestCleanTitle(t *testing.T) {
svc := NewAudioBookService()
in := "Автор - Книга [MP3, 96 kbps] (2025)"
got := svc.CleanTitle(in)
if got != "Автор - Книга" {
t.Errorf("получено %q", got)
}
}

View File

@@ -0,0 +1,520 @@
package services
import (
"regexp"
"strconv"
"strings"
"audio-catalyst/internal/domain/entities"
"golang.org/x/text/encoding/charmap"
)
// MetadataService сервис для работы с метаданными
type MetadataService struct{}
// NewMetadataService создает новый сервис метаданных
func NewMetadataService() *MetadataService {
return &MetadataService{}
}
// ParseTopicMetadata парсит метаданные со страницы темы RuTracker
func (s *MetadataService) ParseTopicMetadata(htmlContent string, torrent entities.Torrent) (*entities.AudioBookMetadata, error) {
// Декодируем из Windows-1251 в UTF-8
decoder := charmap.Windows1251.NewDecoder()
decodedContent, err := decoder.String(htmlContent)
if err != nil {
decodedContent = htmlContent
}
audioBookService := NewAudioBookService()
cleanTitle := audioBookService.CleanTitle(torrent.Title)
metadata := &entities.AudioBookMetadata{
Tags: []string{},
Chapters: []entities.Chapter{},
Title: cleanTitle,
Subtitle: cleanTitle,
Authors: []string{},
Narrators: []string{},
Series: []string{},
Genres: []string{},
Description: "",
Language: "ru",
Explicit: false,
Abridged: false,
}
// Попытаться извлечь заголовок из HTML: <span style="font-size: 24px; ...">Заголовок</span>
if htmlTitle := s.extractHTMLTitle(decodedContent); htmlTitle != "" {
metadata.Title = htmlTitle
}
// Subtitle: берем из <title> страницы (полный заголовок)
if pageTitle := s.extractPageTitle(decodedContent); pageTitle != "" {
metadata.Subtitle = pageTitle
} else if metadata.Subtitle == "" {
metadata.Subtitle = metadata.Title
}
// Функция для извлечения значения после <span class="post-b">метка</span>: значение
extractPostBValue := func(label string) string {
pattern := `<span[^>]*class="[^"]*post-b[^"]*"[^>]*>` + regexp.QuoteMeta(label) + `</span>:\s*([^<\r\n]+)`
re := regexp.MustCompile(`(?i)` + pattern)
matches := re.FindStringSubmatch(decodedContent)
if len(matches) >= 2 {
value := strings.TrimSpace(matches[1])
return s.decodeHTMLEntities(value)
}
return ""
}
// Извлечение года выпуска
if yearStr := extractPostBValue("Год выпуска"); yearStr != "" {
yearMatch := regexp.MustCompile(`(\d{4})`).FindString(yearStr)
if yearMatch != "" && len(yearMatch) == 4 {
if yearMatch >= "1900" && yearMatch <= "2030" {
yearInt := 0
for _, digit := range yearMatch {
yearInt = yearInt*10 + int(digit-'0')
}
metadata.PublishedYear = &yearInt
}
}
}
// Извлечение автора
s.extractAuthors(extractPostBValue, metadata)
// Извлечение исполнителя (чтеца)
s.extractNarrators(extractPostBValue, metadata)
// Извлечение жанра
if genre := extractPostBValue("Жанр"); genre != "" {
genres := regexp.MustCompile(`[,;]\s*`).Split(genre, -1)
for i, g := range genres {
genres[i] = strings.TrimSpace(g)
}
metadata.Genres = genres
}
// Извлечение тегов (категории форума), например: "[Аудио] Российская фантастика, фэнтези, мистика, ужасы, фанфики"
s.extractTags(decodedContent, metadata)
// Извлечение издательства
s.extractPublisher(extractPostBValue, metadata)
// Извлечение ISBN и ASIN
s.extractISBN(extractPostBValue, decodedContent, metadata)
s.extractASIN(extractPostBValue, decodedContent, metadata)
// Извлечение описания
s.extractDescription(extractPostBValue, decodedContent, metadata)
// Извлечение серии
s.extractSeries(extractPostBValue, decodedContent, torrent, metadata)
// Определяем эксплицитность и сокращенность
s.detectContentFlags(decodedContent, metadata)
return metadata, nil
}
// extractAuthors извлекает авторов
func (s *MetadataService) extractAuthors(extractPostBValue func(string) string, metadata *entities.AudioBookMetadata) {
var authorParts []string
if surname := extractPostBValue("Фамилия автора"); surname != "" {
authorParts = append(authorParts, surname)
}
if name := extractPostBValue("Имя автора"); name != "" {
authorParts = append(authorParts, name)
}
if len(authorParts) >= 2 {
metadata.Authors = []string{strings.Join(authorParts, " ")}
} else if len(authorParts) == 1 {
metadata.Authors = []string{authorParts[0]}
}
if len(metadata.Authors) == 0 {
if author := extractPostBValue("Автор"); author != "" {
authors := regexp.MustCompile(`[,;]\s*`).Split(author, -1)
for i, a := range authors {
authors[i] = strings.TrimSpace(a)
}
metadata.Authors = authors
}
}
}
// extractNarrators извлекает чтецов
func (s *MetadataService) extractNarrators(extractPostBValue func(string) string, metadata *entities.AudioBookMetadata) {
if narrator := extractPostBValue("Исполнитель"); narrator != "" {
narrators := regexp.MustCompile(`[,;]\s*`).Split(narrator, -1)
for i, n := range narrators {
narrators[i] = strings.TrimSpace(n)
}
metadata.Narrators = narrators
return
}
narratorFields := []string{"Чтец", "Читает", "Диктор", "Narrator"}
for _, field := range narratorFields {
if narrator := extractPostBValue(field); narrator != "" {
narrators := regexp.MustCompile(`[,;]\s*`).Split(narrator, -1)
for i, n := range narrators {
narrators[i] = strings.TrimSpace(n)
}
metadata.Narrators = narrators
break
}
}
}
// extractPublisher извлекает издательство
func (s *MetadataService) extractPublisher(extractPostBValue func(string) string, metadata *entities.AudioBookMetadata) {
if publisher := extractPostBValue("Издательство"); publisher != "" {
publisher = regexp.MustCompile(`:\s*.*$`).ReplaceAllString(publisher, "")
publisher = strings.TrimSpace(publisher)
if publisher != "" {
metadata.Publisher = &publisher
return
}
}
publisherFields := []string{"Выпущено", "Выпуск", "Издано", "Publisher"}
for _, field := range publisherFields {
if pub := extractPostBValue(field); pub != "" {
pub = regexp.MustCompile(`:\s*.*$`).ReplaceAllString(pub, "")
pub = strings.TrimSpace(pub)
if pub != "" {
metadata.Publisher = &pub
break
}
}
}
}
// extractISBN извлекает ISBN
func (s *MetadataService) extractISBN(extractPostBValue func(string) string, decodedContent string, metadata *entities.AudioBookMetadata) {
if isbn := extractPostBValue("ISBN"); isbn != "" {
metadata.ISBN = &isbn
return
}
isbnPatterns := []string{
`(?i)isbn[\s:-]*(\d{3}[-\s]?\d{1}[-\s]?\d{3}[-\s]?\d{5}[-\s]?\d{1})`,
`(?i)isbn[\s:-]*(\d{1}[-\s]?\d{3}[-\s]?\d{5}[-\s]?\d{1})`,
`(?i)isbn[\s:-]*(\d{13})`,
`(?i)isbn[\s:-]*(\d{10})`,
}
for _, pattern := range isbnPatterns {
if isbnMatch := regexp.MustCompile(pattern).FindStringSubmatch(decodedContent); len(isbnMatch) >= 2 {
isbn := strings.ReplaceAll(isbnMatch[1], " ", "")
isbn = strings.ReplaceAll(isbn, "-", "")
if len(isbn) == 10 || len(isbn) == 13 {
metadata.ISBN = &isbn
break
}
}
}
}
// extractASIN извлекает ASIN
func (s *MetadataService) extractASIN(extractPostBValue func(string) string, decodedContent string, metadata *entities.AudioBookMetadata) {
if asin := extractPostBValue("ASIN"); asin != "" {
metadata.ASIN = &asin
return
}
asinPatterns := []string{
`(?i)asin[\s:-]*([A-Z0-9]{10})`,
`(?i)amazon[\s]*asin[\s:-]*([A-Z0-9]{10})`,
}
for _, pattern := range asinPatterns {
if asinMatch := regexp.MustCompile(pattern).FindStringSubmatch(decodedContent); len(asinMatch) >= 2 {
metadata.ASIN = &asinMatch[1]
break
}
}
}
// extractDescription извлекает описание
func (s *MetadataService) extractDescription(extractPostBValue func(string) string, decodedContent string, metadata *entities.AudioBookMetadata) {
// Сначала пытаемся вытащить "богатое" описание до <span class="post-br"> или следующего label
if rich := s.extractRichField(decodedContent, "Описание"); rich != "" {
metadata.Description = rich
return
}
// Простое извлечение через post-b
if desc := extractPostBValue("Описание"); desc != "" {
metadata.Description = desc
return
}
// Альтернативные поля
descFields := []string{"О книге", "Аннотация", "Summary", "About"}
for _, field := range descFields {
if rich := s.extractRichField(decodedContent, field); rich != "" {
metadata.Description = rich
return
}
if desc := extractPostBValue(field); desc != "" {
metadata.Description = desc
return
}
}
}
// extractRichField извлекает текст после <span class="post-b">label</span> с учётом вариантов двоеточия
func (s *MetadataService) extractRichField(html string, label string) string {
// Варианты:
// 1) <span class="post-b">Описание</span>: TEXT ...
// 2) <span class="post-b">Описание:</span> TEXT ...
// Останавливаемся на <span class="post-br">, следующем <span class="post-b"> или конце текста
pattern := `(?is)<span[^>]*class=["'][^"']*post-b[^"']*["'][^>]*>\s*` + regexp.QuoteMeta(label) + `\s*:?[\s\S]*?</span>\s*:?\s*(.+?)(?:<span[^>]*class=["'][^"']*post-br[^"']*["'][^>]*>|<span[^>]*class=["'][^"']*post-b[^"']*["'][^>]*>|$)`
re := regexp.MustCompile(pattern)
m := re.FindStringSubmatch(html)
if len(m) < 2 {
return ""
}
text := m[1]
// Удаляем HTML теги
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
// Декодируем сущности и нормализуем пробелы
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Убираем хвост вида ": 1." на конце (если вдруг остался из разметки)
text = regexp.MustCompile(`\s*:\s*\d+\.?$`).ReplaceAllString(text, "")
return strings.TrimSpace(text)
}
// extractSeries извлекает серию
func (s *MetadataService) extractSeries(extractPostBValue func(string) string, decodedContent string, torrent entities.Torrent, metadata *entities.AudioBookMetadata) {
// Ищем в post-b полях
if series := extractPostBValue("Серия"); series != "" {
metadata.Series = []string{series}
return
}
seriesFields := []string{"Цикл", "Сага", "Собрание сочинений", "Антология", "Series"}
for _, field := range seriesFields {
if series := extractPostBValue(field); series != "" {
metadata.Series = []string{series}
return
}
}
// Дополнительно: парсим упоминания в тексте/ссылках, например: "Цикл «Наследник»"
patterns := []string{
`(?is)<a[^>]*class=["'][^"']*postLink[^"']*["'][^>]*>\s*Цикл\s*[«\"]\s*([^»\"]+)\s*[»\"]\s*</a>`,
`(?i)Цикл\s*[«\"]\s*([^»\"]+)\s*[»\"]`,
`(?i)Серия\s*[«\"]\s*([^»\"]+)\s*[»\"]`,
`(?i)Сага\s*[«\"]\s*([^»\"]+)\s*[»\"]`,
}
for _, p := range patterns {
if m := regexp.MustCompile(p).FindStringSubmatch(decodedContent); len(m) >= 2 {
name := s.decodeHTMLEntities(strings.TrimSpace(m[1]))
if name != "" {
metadata.Series = []string{name}
return
}
}
}
}
// detectContentFlags определяет флаги контента
func (s *MetadataService) detectContentFlags(decodedContent string, metadata *entities.AudioBookMetadata) {
contentLower := strings.ToLower(decodedContent)
// Эксплицитность
explicitKeywords := []string{"18+", "эротик", "порно", "xxx", "adult", "explicit"}
for _, keyword := range explicitKeywords {
if strings.Contains(contentLower, keyword) {
metadata.Explicit = true
break
}
}
// Сокращенность
abridgedKeywords := []string{"сокращен", "краткая", "abridged", "shortened"}
for _, keyword := range abridgedKeywords {
if strings.Contains(contentLower, keyword) {
metadata.Abridged = true
break
}
}
}
// decodeHTMLEntities декодирует HTML-сущности (включая числовые)
func (s *MetadataService) decodeHTMLEntities(str string) string {
replacements := map[string]string{
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&apos;": "'",
"&nbsp;": " ",
"&#39;": "'",
"&#34;": "\"",
"&laquo;": "«",
"&raquo;": "»",
"&ndash;": "",
"&mdash;": "—",
"&hellip;": "…",
}
result := str
for entity, replacement := range replacements {
result = strings.ReplaceAll(result, entity, replacement)
}
// Декодируем числовые сущности: десятичные и шестнадцатеричные
decRe := regexp.MustCompile(`&#(\d+);`)
result = decRe.ReplaceAllStringFunc(result, func(m string) string {
parts := decRe.FindStringSubmatch(m)
if len(parts) < 2 {
return m
}
if code, err := strconv.Atoi(parts[1]); err == nil && code > 0 {
return string(rune(code))
}
return m
})
hexRe := regexp.MustCompile(`&#x([0-9a-fA-F]+);`)
result = hexRe.ReplaceAllStringFunc(result, func(m string) string {
parts := hexRe.FindStringSubmatch(m)
if len(parts) < 2 {
return m
}
if code, err := strconv.ParseInt(parts[1], 16, 32); err == nil && code > 0 {
return string(rune(code))
}
return m
})
return result
}
// extractHTMLTitle ищет заголовок в разметке поста, затем в og:title и <title>
func (s *MetadataService) extractHTMLTitle(html string) string {
// Сначала пытаемся ограничиться телом первого поста
postBodyRe := regexp.MustCompile(`(?is)<div[^>]*class=["'][^"']*post_body[^"']*["'][^>]*>(.*?)</div>`)
scope := html
if m := postBodyRe.FindStringSubmatch(html); len(m) >= 2 {
scope = m[1]
}
// Ищем span с font-size: 24px (допускаем пробел между 24 и px)
spanRe := regexp.MustCompile(`(?is)<span[^>]*style=["'][^"']*font-size\s*:\s*24\s*px[^"']*["'][^>]*>\s*(.*?)\s*</span>`)
if m := spanRe.FindStringSubmatch(scope); len(m) >= 2 {
text := m[1]
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return s.normalizeTitle(strings.TrimSpace(text))
}
// Фолбэк: og:title
if m := regexp.MustCompile(`(?is)<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']`).FindStringSubmatch(html); len(m) >= 2 {
text := s.decodeHTMLEntities(strings.TrimSpace(m[1]))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return s.normalizeTitle(strings.TrimSpace(text))
}
// Фолбэк: <title>
if m := regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`).FindStringSubmatch(html); len(m) >= 2 {
text := regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(m[1], " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
return s.normalizeTitle(strings.TrimSpace(text))
}
return ""
}
// extractPageTitle извлекает содержимое тега <title> без агрессивной нормализации
func (s *MetadataService) extractPageTitle(html string) string {
re := regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
m := re.FindStringSubmatch(html)
if len(m) < 2 {
return ""
}
text := m[1]
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Убираем хвосты вида " :: RuTracker.org" / " | RuTracker.org" / " - RuTracker.org"
text = s.stripSiteSuffix(text)
return strings.TrimSpace(text)
}
// stripSiteSuffix убирает служебные суффиксы сайта в конце заголовка
func (s *MetadataService) stripSiteSuffix(t string) string {
if t == "" {
return t
}
// Удаляем любые повторяющиеся разделители перед RuTracker.org на конце
re := regexp.MustCompile(`(?i)\s*(?:\s*(?:\||::|:|—|-)\s*)+RuTracker\.?org.*$`)
return strings.TrimSpace(re.ReplaceAllString(t, ""))
}
// normalizeTitle чистит служебные хвосты и префиксы автора
func (s *MetadataService) normalizeTitle(t string) string {
if t == "" {
return t
}
// Убираем упоминание сайта и мусорные хвосты
t = regexp.MustCompile(`(?i)\s*[:|\-—]*\s*RuTracker\.?org.*$`).ReplaceAllString(t, "")
t = regexp.MustCompile(`\s*\[[^\]]*\]\s*$`).ReplaceAllString(t, "") // хвосты в [скобках]
t = regexp.MustCompile(`\s*\([^\)]*\)\s*$`).ReplaceAllString(t, "") // хвосты в (скобках)
// Если формат "Автор - Название" / "Автор — Название" — берем правую часть
if parts := regexp.MustCompile(`\s+[\-—]\s+`).Split(t, 2); len(parts) == 2 {
// Берём правую часть, если она не пустая
if strings.TrimSpace(parts[1]) != "" {
return strings.TrimSpace(parts[1])
}
}
return strings.TrimSpace(t)
}
// extractTags ищет ссылку на раздел форума (viewforum.php) и кладёт её текст в Tags одним элементом
func (s *MetadataService) extractTags(html string, metadata *entities.AudioBookMetadata) {
re := regexp.MustCompile(`(?is)<a[^>]+href=["'][^"']*viewforum\\.php\?f=\d+[^"']*["'][^>]*>\s*(.*?)\s*</a>`)
matches := re.FindAllStringSubmatch(html, -1)
best := ""
bestScore := -1
bestLen := 0
for _, m := range matches {
if len(m) < 2 {
continue
}
text := m[1]
text = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(text, " ")
text = s.decodeHTMLEntities(strings.TrimSpace(text))
text = regexp.MustCompile(`\s+`).ReplaceAllString(text, " ")
// Убираем префикс в квадратных скобках, например [Аудио]
text = regexp.MustCompile(`^\[[^\]]+\]\s*`).ReplaceAllString(text, "")
if text == "" {
continue
}
lower := strings.ToLower(text)
score := 0
for _, kw := range []string{"фантаст", "фэнтез", "мистик", "ужас", "фанфик"} {
if strings.Contains(lower, kw) {
score++
}
}
if score > bestScore || (score == bestScore && len(text) > bestLen) {
best = text
bestScore = score
bestLen = len(text)
}
}
if best != "" {
metadata.Tags = []string{best}
}
}

View File

@@ -0,0 +1,42 @@
package services
import (
"testing"
"audio-catalyst/internal/domain/entities"
)
func TestExtractHTMLTitleAndSubtitle(t *testing.T) {
s := NewMetadataService()
html := `<html><head><title>Автор - Полный тайтл :: RuTracker.org</title></head>
<div class="post_body"><span style="font-size: 24px; line-height: normal;">Наследник. Книга 03</span></div></html>`
m, err := s.ParseTopicMetadata(html, entities.Torrent{Title: "Папка"})
if err != nil {
t.Fatal(err)
}
if m.Title != "Наследник. Книга 03" {
t.Errorf("title=%q", m.Title)
}
if m.Subtitle != "Автор - Полный тайтл" {
t.Errorf("subtitle=%q", m.Subtitle)
}
}
func TestExtractSeriesAndTags(t *testing.T) {
s := NewMetadataService()
h := `<div class="post_body">
<a href="viewforum.php?f=2387">[Аудио] Российская фантастика, фэнтези, мистика, ужасы, фанфики</a>
<a href="tracker.php?f=2387" class="postLink">Цикл «Наследник»</a>
<span class="post-b">Описание</span>: Текст
</div>`
m, err := s.ParseTopicMetadata(h, entities.Torrent{Title: "X"})
if err != nil {
t.Fatal(err)
}
if len(m.Series) == 0 || m.Series[0] != "Наследник" {
t.Errorf("series=%v", m.Series)
}
if len(m.Tags) == 0 || m.Tags[0] != "Российская фантастика, фэнтези, мистика, ужасы, фанфики" {
t.Errorf("tags=%v", m.Tags)
}
}

View 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
}

View 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
}

View 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)
}
}

View 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()
}

View 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 ""
}

View 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)
}
}
}

View File

@@ -0,0 +1,57 @@
package tui
import (
"fmt"
"audio-catalyst/internal/domain/repositories"
)
// UILogger адаптер, который дублирует логи в TUI и (опционально) в базовый логгер
type UILogger struct {
base repositories.Logger // может быть nil (тогда лог только в TUI)
mgr *Manager
}
func NewUILogger(base repositories.Logger, mgr *Manager) *UILogger {
return &UILogger{base: base, mgr: mgr}
}
func (l *UILogger) Info(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
if l.base != nil {
l.base.Info("%s", msg)
}
l.mgr.SendLogUpdate("[white]" + msg)
}
func (l *UILogger) Debug(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
if l.base != nil {
l.base.Debug("%s", msg)
}
l.mgr.SendLogUpdate("[gray]" + msg)
}
func (l *UILogger) Warning(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
if l.base != nil {
l.base.Warning("%s", msg)
}
l.mgr.SendLogUpdate("[yellow]" + msg)
}
func (l *UILogger) Error(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
if l.base != nil {
l.base.Error("%s", msg)
}
l.mgr.SendLogUpdate("[red]" + msg)
}
func (l *UILogger) Success(format string, args ...interface{}) {
msg := fmt.Sprintf(format, args...)
if l.base != nil {
l.base.Success("%s", msg)
}
l.mgr.SendLogUpdate("[green]" + msg)
}

View File

@@ -0,0 +1,343 @@
package tui
import (
"fmt"
"strings"
"sync"
"audio-catalyst/internal/domain/entities"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// Manager управляет TUI интерфейсом
type Manager struct {
app *tview.Application
pages *tview.Pages
currentScreen entities.UIScreen
// Основные компоненты
mainFlex *tview.Flex
headerBar *tview.TextView
footerBar *tview.TextView
statusBar *tview.TextView
// Компоненты главного меню
menuList *tview.List
infoPanel *tview.TextView
// Компоненты обработки
logView *tview.TextView
progressBar *tview.TextView
progressText *tview.TextView
// Компоненты настроек
settingsForm *tview.Form
// Компоненты результатов
resultsTable *tview.Table
detailView *tview.TextView
// Состояние
logBuffer []string
maxLogLines int
mu sync.Mutex
// Каналы для обновлений
statusUpdate chan entities.ProcessingStatus
logUpdate chan string
// Колбэки действий UI
onStartProcessing func()
}
// NewManager создает новый TUI менеджер
func NewManager() *Manager {
return &Manager{
app: tview.NewApplication(),
maxLogLines: 100,
logBuffer: make([]string, 0),
statusUpdate: make(chan entities.ProcessingStatus, 100),
logUpdate: make(chan string, 100),
}
}
// SetOnStartProcessing регистрирует обработчик запуска обработки
func (m *Manager) SetOnStartProcessing(handler func()) {
m.onStartProcessing = handler
}
// Initialize инициализирует UI компоненты
func (m *Manager) Initialize() {
m.initializeComponents()
m.setupMainMenu()
m.setupProcessingScreen()
m.setupSettingsScreen()
m.setupResultsScreen()
m.setupPages()
}
// Run запускает TUI приложение
func (m *Manager) Run() error {
go m.handleUpdates()
return m.app.Run()
}
// Stop останавливает TUI приложение
func (m *Manager) Stop() {
m.app.Stop()
}
// SendStatusUpdate отправляет обновление статуса
func (m *Manager) SendStatusUpdate(status entities.ProcessingStatus) {
select {
case m.statusUpdate <- status:
default:
}
}
// SendLogUpdate отправляет обновление лога
func (m *Manager) SendLogUpdate(message string) {
select {
case m.logUpdate <- message:
default:
}
}
// initializeComponents инициализирует основные компоненты
func (m *Manager) initializeComponents() {
// Создаем основные элементы
m.headerBar = tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("[blue::b]🎵 AudioBook Catalyst v1.0 🎵[-:-:-]")
m.headerBar.SetBorder(true)
m.footerBar = tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("[grey]ESC: Выход | TAB: Навигация | ENTER: Выбор[-]")
m.statusBar = tview.NewTextView().
SetDynamicColors(true).
SetText("[green]Готов к работе[-]")
// Создаем систему страниц заранее
m.pages = tview.NewPages()
// Создаем главный контейнер: Header | Pages | Status | Footer
m.mainFlex = tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(m.headerBar, 3, 0, false).
AddItem(m.pages, 0, 1, true).
AddItem(m.statusBar, 1, 0, false).
AddItem(m.footerBar, 1, 0, false)
}
// setupMainMenu настраивает главное меню
func (m *Manager) setupMainMenu() {
m.menuList = tview.NewList().
AddItem("🚀 Начать обработку аудиокниг", "Начать сканирование и обработку аудиокниг", '1', func() {
m.switchToProcessing()
if m.onStartProcessing != nil {
go m.onStartProcessing()
}
}).
AddItem("⚙️ Настройки", "Настройка параметров приложения", '2', func() {
m.switchToSettings()
}).
AddItem("📊 Результаты", "Просмотр результатов обработки", '3', func() {
m.switchToResults()
}).
AddItem("❌ Выход", "Завершить работу приложения", 'q', func() {
m.app.Stop()
})
m.menuList.SetBorder(true).SetTitle(" Главное меню ")
m.infoPanel = tview.NewTextView().
SetDynamicColors(true).
SetWrap(true).
SetText(`[yellow]AudioBook Catalyst[-]
Приложение для автоматической обработки аудиокниг:
• Сканирование папок с MP3 файлами
• Поиск метаданных на RuTracker
• Создание файлов metadata.json
• Загрузка обложек
[grey]Используйте клавиши 1-4 или мышь для навигации[-]`)
m.infoPanel.SetBorder(true).SetTitle(" Информация ")
mainMenuFlex := tview.NewFlex().
AddItem(m.menuList, 0, 1, true).
AddItem(m.infoPanel, 0, 1, false)
m.pages.AddPage("main", mainMenuFlex, true, true)
}
// setupProcessingScreen настраивает экран обработки
func (m *Manager) setupProcessingScreen() {
m.logView = tview.NewTextView().
SetDynamicColors(true).
SetScrollable(true).
SetWrap(true)
m.logView.SetBorder(true).SetTitle(" Лог обработки ")
m.progressBar = tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter)
m.progressBar.SetBorder(true).SetTitle(" Прогресс ")
m.progressText = tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("[white]Ожидание начала обработки...[-]")
progressFlex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(m.progressBar, 3, 0, false).
AddItem(m.progressText, 1, 0, false)
processingFlex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(progressFlex, 4, 0, false).
AddItem(m.logView, 0, 1, true)
m.pages.AddPage("processing", processingFlex, true, false)
}
// setupSettingsScreen настраивает экран настроек
func (m *Manager) setupSettingsScreen() {
m.settingsForm = tview.NewForm().
AddInputField("Исходная директория:", "", 50, nil, nil).
AddInputField("Целевая директория:", "", 50, nil, nil).
AddInputField("Имя пользователя RuTracker:", "", 30, nil, nil).
AddPasswordField("Пароль RuTracker:", "", 30, '*', nil).
AddDropDown("Уровень логирования:", []string{"info", "debug"}, 0, nil).
AddButton("Сохранить", func() {
// TODO: Сохранить настройки
}).
AddButton("Отмена", func() {
m.switchToMain()
})
m.settingsForm.SetBorder(true).SetTitle(" Настройки ")
m.pages.AddPage("settings", m.settingsForm, true, false)
}
// setupResultsScreen настраивает экран результатов
func (m *Manager) setupResultsScreen() {
m.resultsTable = tview.NewTable().
SetSelectable(true, false)
m.resultsTable.SetBorder(true).SetTitle(" Обработанные аудиокниги ")
m.detailView = tview.NewTextView().
SetDynamicColors(true).
SetWrap(true)
m.detailView.SetBorder(true).SetTitle(" Детали ")
resultsFlex := tview.NewFlex().
AddItem(m.resultsTable, 0, 2, true).
AddItem(m.detailView, 0, 1, false)
m.pages.AddPage("results", resultsFlex, true, false)
}
// setupPages настраивает систему страниц
func (m *Manager) setupPages() {
m.app.SetRoot(m.mainFlex, true)
// Глобальные горячие клавиши
m.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEscape:
m.switchToMain()
return nil
case tcell.KeyF1:
m.switchToMain()
return nil
case tcell.KeyF2:
m.switchToProcessing()
return nil
case tcell.KeyF3:
m.switchToSettings()
return nil
case tcell.KeyF4:
m.switchToResults()
return nil
}
return event
})
}
// handleUpdates обрабатывает обновления в горутине
func (m *Manager) handleUpdates() {
for {
select {
case status := <-m.statusUpdate:
m.app.QueueUpdateDraw(func() {
m.updateProgress(status)
})
case logMsg := <-m.logUpdate:
m.app.QueueUpdateDraw(func() {
m.addLogMessage(logMsg)
})
}
}
}
// updateProgress обновляет прогресс
func (m *Manager) updateProgress(status entities.ProcessingStatus) {
if status.Total > 0 {
progress := float64(status.Current) / float64(status.Total) * 100
m.progressBar.SetText(fmt.Sprintf("[green]%.1f%% (%d/%d)[-]", progress, status.Current, status.Total))
}
if status.Error != nil {
m.progressText.SetText(fmt.Sprintf("[red]Ошибка: %s[-]", status.Error.Error()))
} else {
m.progressText.SetText(fmt.Sprintf("[white]%s[-]", status.Status))
}
}
// addLogMessage добавляет сообщение в лог
func (m *Manager) addLogMessage(message string) {
m.mu.Lock()
defer m.mu.Unlock()
m.logBuffer = append(m.logBuffer, message)
if len(m.logBuffer) > m.maxLogLines {
m.logBuffer = m.logBuffer[1:]
}
m.logView.SetText(strings.Join(m.logBuffer, "\n"))
m.logView.ScrollToEnd()
}
// Методы переключения страниц
func (m *Manager) switchToMain() {
m.currentScreen = entities.ScreenMainMenu
m.pages.SwitchToPage("main")
m.app.SetFocus(m.menuList)
}
func (m *Manager) switchToProcessing() {
m.currentScreen = entities.ScreenProcessing
m.pages.SwitchToPage("processing")
m.app.SetFocus(m.logView)
}
func (m *Manager) switchToSettings() {
m.currentScreen = entities.ScreenSettings
m.pages.SwitchToPage("settings")
m.app.SetFocus(m.settingsForm)
}
func (m *Manager) switchToResults() {
m.currentScreen = entities.ScreenResults
m.pages.SwitchToPage("results")
m.app.SetFocus(m.resultsTable)
}