Добавить тесты репозитория файловой системы и реализовать функциональность журналирования файлов.
- Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы. - Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов. - Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов. - Добавить тесты для нормализации URL в репозиторий RuTracker. - Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер. - Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов.
This commit is contained in:
49
.gitignore
vendored
49
.gitignore
vendored
@@ -1,27 +1,34 @@
|
|||||||
# ---> Go
|
# Примеры папок с аудиокнигами
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
audiobooks/
|
||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
cover.jpg
|
||||||
#
|
description.txt
|
||||||
# Binaries for programs and plugins
|
*.log
|
||||||
|
|
||||||
|
# Организованная библиотека
|
||||||
|
organized/
|
||||||
|
|
||||||
|
# Конфигурационные файлы
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Исполняемые файлы
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
|
||||||
*.test
|
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
|
||||||
*.out
|
*.out
|
||||||
|
audio-catalyst
|
||||||
|
|
||||||
# Dependency directories (remove the comment below to include it)
|
# Go временные файлы
|
||||||
# vendor/
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
# Go workspace file
|
# IDE файлы
|
||||||
go.work
|
.vscode/
|
||||||
go.work.sum
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
# env file
|
# Системные файлы
|
||||||
.env
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|||||||
122
ARCHITECTURE.md
Normal file
122
ARCHITECTURE.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Архитектура AudioBook Catalyst после рефакторинга
|
||||||
|
|
||||||
|
## Структура приложения
|
||||||
|
|
||||||
|
```
|
||||||
|
AudioBook Catalyst
|
||||||
|
├── Конфигурация
|
||||||
|
│ ├── Config struct (YAML)
|
||||||
|
│ └── HTTP клиенты (основной + OpenRouter)
|
||||||
|
│
|
||||||
|
├── Сканирование файлов
|
||||||
|
│ ├── scanAudioBooks() ✅ активно
|
||||||
|
│ ├── findMP3Files() ✅ активно
|
||||||
|
│ ├── findCoverFile() ✅ активно
|
||||||
|
│ └── findDescriptionFile() ✅ активно
|
||||||
|
│
|
||||||
|
├── RuTracker интеграция
|
||||||
|
│ ├── 🆕 RuTrackerClient класс ✅ НОВЫЙ
|
||||||
|
│ │ ├── NewRuTrackerClient()
|
||||||
|
│ │ ├── Search()
|
||||||
|
│ │ ├── DownloadTorrent()
|
||||||
|
│ │ └── GetTopicPage()
|
||||||
|
│ │
|
||||||
|
│ └── 🚫 Старые функции (закомментированы)
|
||||||
|
│ ├── authenticateRuTracker() ❌ устарело
|
||||||
|
│ ├── searchRuTracker() ❌ устарело
|
||||||
|
│ ├── parseAudioBookPage() ❌ устарело
|
||||||
|
│ └── ExtractAudiobookID() ❌ устарело
|
||||||
|
│
|
||||||
|
├── Обработка метаданных
|
||||||
|
│ ├── createMetadata() ✅ активно
|
||||||
|
│ ├── saveMetadata() ✅ активно
|
||||||
|
│ ├── downloadCover() ✅ активно
|
||||||
|
│ └── Парсеры (Authors, Narrators, Genres) ✅ активно
|
||||||
|
│
|
||||||
|
├── TUI интерфейс
|
||||||
|
│ ├── TUIManager ✅ активно
|
||||||
|
│ ├── Главное меню ✅ активно
|
||||||
|
│ ├── Тест RuTracker клиента ✅ НОВЫЙ
|
||||||
|
│ └── Настройки ✅ активно
|
||||||
|
│
|
||||||
|
└── Система логирования
|
||||||
|
├── Файловое логирование ✅ активно
|
||||||
|
├── TUI логирование ✅ активно
|
||||||
|
└── Цветной вывод ✅ активно
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ключевые изменения
|
||||||
|
|
||||||
|
### ✅ Что работает
|
||||||
|
- **Новый RuTrackerClient**: Полностью функциональный объектно-ориентированный клиент
|
||||||
|
- **Совместимые функции**: Все функции работы с метаданными и файлами
|
||||||
|
- **TUI интерфейс**: Полностью работающий интерфейс с новым пунктом тестирования
|
||||||
|
- **Система логирования**: Без изменений, полностью функциональна
|
||||||
|
|
||||||
|
### 🚫 Что закомментировано
|
||||||
|
- **Старая авторизация**: Устаревшие методы authenticateRuTracker/authenticateWithoutToken
|
||||||
|
- **Старый поиск**: Функция searchRuTracker и связанные парсеры
|
||||||
|
- **OpenRouter интеграция**: Анализ HTML через AI (пока закомментирован)
|
||||||
|
|
||||||
|
### 🔄 Что нужно доработать
|
||||||
|
- Интеграция нового клиента в основной процесс обработки
|
||||||
|
- Замена временного кода в processAudioBook()
|
||||||
|
- Тестирование всех функций нового клиента
|
||||||
|
|
||||||
|
## API нового RuTrackerClient
|
||||||
|
|
||||||
|
### Создание клиента
|
||||||
|
```go
|
||||||
|
client, err := NewRuTrackerClient(username, password)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поиск торрентов
|
||||||
|
```go
|
||||||
|
results, err := client.Search("поисковый запрос")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение информации о теме
|
||||||
|
```go
|
||||||
|
pageContent, err := client.GetTopicPage(topicID)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Загрузка торрент-файла
|
||||||
|
```go
|
||||||
|
torrentData, err := client.DownloadTorrent(downloadURL)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Преимущества новой архитектуры
|
||||||
|
|
||||||
|
### 🏗️ Структурированность
|
||||||
|
- Четкое разделение ответственности
|
||||||
|
- Объектно-ориентированный подход
|
||||||
|
- Типизированные структуры данных
|
||||||
|
|
||||||
|
### 🚀 Производительность
|
||||||
|
- Переиспользование HTTP соединений
|
||||||
|
- Параллельная обработка страниц результатов
|
||||||
|
- Оптимизированные регулярные выражения
|
||||||
|
|
||||||
|
### 🛡️ Надежность
|
||||||
|
- Автоматическое переключение между зеркалами
|
||||||
|
- Правильная обработка кодировки CP1251
|
||||||
|
- Централизованная обработка ошибок
|
||||||
|
|
||||||
|
### 🔧 Расширяемость
|
||||||
|
- Легко добавлять новые методы
|
||||||
|
- Модульная архитектура
|
||||||
|
- API для интеграции с другими компонентами
|
||||||
|
|
||||||
|
## Состояние проекта
|
||||||
|
|
||||||
|
✅ **Компилируется без ошибок**
|
||||||
|
✅ **Запускается и работает TUI**
|
||||||
|
✅ **Новый RuTracker клиент готов к использованию**
|
||||||
|
🔄 **Требует интеграции в основной процесс обработки**
|
||||||
|
|
||||||
|
## Следующие шаги
|
||||||
|
|
||||||
|
1. Интеграция нового клиента в processAudioBook()
|
||||||
|
2. Тестирование поиска и загрузки метаданных
|
||||||
|
3. Валидация работы с реальными данными RuTracker
|
||||||
|
4. Удаление закомментированного кода после успешного тестирования
|
||||||
23
LICENSE
Normal file
23
LICENSE
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
Лицензия MIT
|
||||||
|
|
||||||
|
Авторские права (c) 2025 AudioBook Catalyst
|
||||||
|
|
||||||
|
Настоящим предоставляется бесплатное разрешение любому лицу, получившему копию
|
||||||
|
данного программного обеспечения и связанных с ним файлов документации
|
||||||
|
("Программное обеспечение"), использовать Программное обеспечение без ограничений,
|
||||||
|
включая неограниченное право на использование, копирование, изменение, слияние,
|
||||||
|
публикацию, распространение, сублицензирование и/или продажу копий Программного
|
||||||
|
обеспечения, а также лицам, которым предоставляется данное Программное
|
||||||
|
обеспечение, при соблюдении следующих условий:
|
||||||
|
|
||||||
|
Указанное выше уведомление об авторском праве и данные условия должны быть
|
||||||
|
включены во все копии или значительные части данного Программного обеспечения.
|
||||||
|
|
||||||
|
ДАННОЕ ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ», БЕЗ КАКИХ-ЛИБО
|
||||||
|
ГАРАНТИЙ, ЯВНО ВЫРАЖЕННЫХ ИЛИ ПОДРАЗУМЕВАЕМЫХ, ВКЛЮЧАЯ ГАРАНТИИ ТОВАРНОЙ
|
||||||
|
ПРИГОДНОСТИ, СООТВЕТСТВИЯ ПО ЕГО КОНКРЕТНОМУ НАЗНАЧЕНИЮ И ОТСУТСТВИЯ НАРУШЕНИЙ,
|
||||||
|
НО НЕ ОГРАНИЧИВАЯСЬ ИМИ. НИ В КАКОМ СЛУЧАЕ АВТОРЫ ИЛИ ПРАВООБЛАДАТЕЛИ НЕ НЕСУТ
|
||||||
|
ОТВЕТСТВЕННОСТИ ПО КАКИМ-ЛИБО ИСКАМ, ЗА УЩЕРБ ИЛИ ПО ИНЫМ ТРЕБОВАНИЯМ, В ТОМ
|
||||||
|
ЧИСЛЕ, ПРИ ДЕЙСТВИИ ДОГОВОРА, ДЕЛИКТЕ ИЛИ ИНОЙ СИТУАЦИИ, ВОЗНИКШИМ ИЗ-ЗА
|
||||||
|
ИСПОЛЬЗОВАНИЯ ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ ИЛИ ИНЫХ ДЕЙСТВИЙ С ПРОГРАММНЫМ
|
||||||
|
ОБЕСПЕЧЕНИЕМ.
|
||||||
114
Makefile
Normal file
114
Makefile
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# AudioBook Catalyst Makefile
|
||||||
|
# Удобные команды для разработки и тестирования
|
||||||
|
|
||||||
|
# Переменные
|
||||||
|
BINARY_NAME=audio-catalyst.exe
|
||||||
|
MAIN_PACKAGE=./cmd
|
||||||
|
TEST_TIMEOUT=30s
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
GREEN=\033[0;32m
|
||||||
|
YELLOW=\033[0;33m
|
||||||
|
RED=\033[0;31m
|
||||||
|
NC=\033[0m # No Color
|
||||||
|
|
||||||
|
.PHONY: help build test clean run fmt vet deps benchmark coverage install
|
||||||
|
|
||||||
|
# Помощь
|
||||||
|
help: ## Показать это сообщение помощи
|
||||||
|
@echo "$(YELLOW)AudioBook Catalyst - Команды разработки$(NC)"
|
||||||
|
@echo ""
|
||||||
|
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "$(GREEN)%-15s$(NC) %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
# Основные команды
|
||||||
|
build: ## Собрать приложение
|
||||||
|
@echo "$(YELLOW)🔨 Сборка AudioBook Catalyst...$(NC)"
|
||||||
|
@powershell -Command "$$env:GOOS='windows'; $$env:GOARCH='amd64'; $$env:CGO_ENABLED='1'; go build -o $(BINARY_NAME) $(MAIN_PACKAGE)"
|
||||||
|
@echo "$(GREEN)✅ Сборка завершена: $(BINARY_NAME)$(NC)"
|
||||||
|
|
||||||
|
run: build ## Собрать и запустить приложение
|
||||||
|
@echo "$(YELLOW)🚀 Запуск AudioBook Catalyst...$(NC)"
|
||||||
|
@powershell -Command ".\$(BINARY_NAME)"
|
||||||
|
|
||||||
|
test: ## Запустить все тесты
|
||||||
|
@echo "$(YELLOW)🧪 Запуск тестов...$(NC)"
|
||||||
|
@powershell -Command "$$env:GOOS='windows'; $$env:GOARCH='amd64'; $$env:CGO_ENABLED='1'; go test -v -timeout $(TEST_TIMEOUT) ./..."
|
||||||
|
@echo "$(GREEN)✅ Тесты завершены$(NC)"
|
||||||
|
|
||||||
|
benchmark: ## Запустить бенчмарки
|
||||||
|
@echo "$(YELLOW)⚡ Запуск бенчмарков...$(NC)"
|
||||||
|
@powershell -Command "$$env:GOOS='windows'; $$env:GOARCH='amd64'; $$env:CGO_ENABLED='1'; go test -bench=. -benchmem ./..."
|
||||||
|
@echo "$(GREEN)✅ Бенчмарки завершены$(NC)"
|
||||||
|
|
||||||
|
coverage: ## Запустить тесты с анализом покрытия
|
||||||
|
@echo "$(YELLOW)📊 Анализ покрытия кода...$(NC)"
|
||||||
|
@powershell -Command "$$env:GOOS='windows'; $$env:GOARCH='amd64'; $$env:CGO_ENABLED='1'; go test -v -cover -coverprofile=coverage.out ./..."
|
||||||
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
@echo "$(GREEN)✅ Отчет о покрытии создан: coverage.html$(NC)"
|
||||||
|
|
||||||
|
# Качество кода
|
||||||
|
fmt: ## Форматировать код
|
||||||
|
@echo "$(YELLOW)🎨 Форматирование кода...$(NC)"
|
||||||
|
go fmt ./...
|
||||||
|
@echo "$(GREEN)✅ Код отформатирован$(NC)"
|
||||||
|
|
||||||
|
vet: ## Статический анализ кода
|
||||||
|
@echo "$(YELLOW)🔍 Статический анализ...$(NC)"
|
||||||
|
go vet ./...
|
||||||
|
@echo "$(GREEN)✅ Анализ завершен$(NC)"
|
||||||
|
|
||||||
|
lint: ## Запустить линтер (требует golangci-lint)
|
||||||
|
@echo "$(YELLOW)🔎 Запуск линтера...$(NC)"
|
||||||
|
golangci-lint run
|
||||||
|
@echo "$(GREEN)✅ Линтинг завершен$(NC)"
|
||||||
|
|
||||||
|
# Зависимости
|
||||||
|
deps: ## Обновить зависимости
|
||||||
|
@echo "$(YELLOW)📦 Обновление зависимостей...$(NC)"
|
||||||
|
go mod tidy
|
||||||
|
go mod download
|
||||||
|
@echo "$(GREEN)✅ Зависимости обновлены$(NC)"
|
||||||
|
|
||||||
|
deps-verify: ## Проверить зависимости
|
||||||
|
@echo "$(YELLOW)🔐 Проверка зависимостей...$(NC)"
|
||||||
|
go mod verify
|
||||||
|
@echo "$(GREEN)✅ Зависимости проверены$(NC)"
|
||||||
|
|
||||||
|
# Очистка
|
||||||
|
clean: ## Очистить артефакты сборки
|
||||||
|
@echo "$(YELLOW)🧹 Очистка...$(NC)"
|
||||||
|
@powershell -Command "go clean; Remove-Item -Force -ErrorAction SilentlyContinue $(BINARY_NAME); Remove-Item -Force -ErrorAction SilentlyContinue coverage.out, coverage.html"
|
||||||
|
@echo "$(GREEN)✅ Очистка завершена$(NC)"
|
||||||
|
|
||||||
|
clean-cache: ## Очистить кэш Go
|
||||||
|
@echo "$(YELLOW)🗑️ Очистка кэша Go...$(NC)"
|
||||||
|
go clean -cache -modcache -testcache
|
||||||
|
@echo "$(GREEN)✅ Кэш очищен$(NC)"
|
||||||
|
|
||||||
|
# Установка инструментов разработки
|
||||||
|
install-tools: ## Установить инструменты разработки
|
||||||
|
@echo "$(YELLOW)🛠️ Установка инструментов...$(NC)"
|
||||||
|
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||||
|
@echo "$(GREEN)✅ Инструменты установлены$(NC)"
|
||||||
|
|
||||||
|
# Комплексные команды
|
||||||
|
check: fmt vet test ## Полная проверка кода (форматирование + анализ + тесты)
|
||||||
|
@echo "$(GREEN)✅ Все проверки пройдены$(NC)"
|
||||||
|
|
||||||
|
release: clean check build ## Подготовка к релизу
|
||||||
|
@echo "$(GREEN)🎉 Готово к релизу!$(NC)"
|
||||||
|
|
||||||
|
# Отладка
|
||||||
|
debug: ## Информация о Go окружении
|
||||||
|
@echo "$(YELLOW)🔧 Информация о Go:$(NC)"
|
||||||
|
@echo "Go version: $$(go version)"
|
||||||
|
@echo "GOOS: $$(go env GOOS)"
|
||||||
|
@echo "GOARCH: $$(go env GOARCH)"
|
||||||
|
@echo "GOPATH: $$(go env GOPATH)"
|
||||||
|
@echo "GOROOT: $$(go env GOROOT)"
|
||||||
|
|
||||||
|
# Быстрые команды
|
||||||
|
t: test ## Быстрый алиас для test
|
||||||
|
b: build ## Быстрый алиас для build
|
||||||
|
r: run ## Быстрый алиас для run
|
||||||
|
c: clean ## Быстрый алиас для clean
|
||||||
151
README.md
151
README.md
@@ -1,2 +1,151 @@
|
|||||||
# audio-catalyst
|
# AudioBook Catalyst
|
||||||
|
|
||||||
|
AudioBook Catalyst — утилита для автоматической обработки аудиокниг: поиск метаданных на RuTracker, формирование глав, скачивание обложек, сохранение metadata.json и организация папок библиотеки.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
- Сканирование директории с аудиокнигами (MP3).
|
||||||
|
- Поиск и парсинг метаданных со страниц RuTracker.
|
||||||
|
- Извлечение полей: Title, Subtitle, Authors, Narrators, Series, Genres, Year, Publisher, Description, Chapters, Tags.
|
||||||
|
- Корректный парсинг заголовка из `<span style="font-size: 24px">` и подзаголовка из `<title>` без хвостов `RuTracker.org`.
|
||||||
|
- Скачивание обложки и сохранение `cover.jpg/png`.
|
||||||
|
- Автоматическое создание глав по MP3-файлам с примерной длительностью, стартами/финишами.
|
||||||
|
- Переименование папки книги по подзаголовку и перенос в организованную библиотеку: `organized/<Буква>/<Автор>/<Книга>`.
|
||||||
|
- TUI-интерфейс на базе tview.
|
||||||
|
- Логи в TUI и файл.
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
```
|
||||||
|
internal/
|
||||||
|
application/
|
||||||
|
usecases/ # Процессы: обработка аудиокниг
|
||||||
|
domain/
|
||||||
|
entities/ # Сущности: AudioBook, AudioBookMetadata, Torrent, ...
|
||||||
|
repositories/ # Интерфейсы репозиториев (FS, RuTracker, Logger)
|
||||||
|
services/ # Бизнес-логика: AudioBookService, MetadataService
|
||||||
|
infrastructure/
|
||||||
|
config/ # Репозиторий конфигурации (YAML)
|
||||||
|
filesystem/ # Репозиторий файловой системы
|
||||||
|
logging/ # Логирование
|
||||||
|
rutracker/ # Клиент RuTracker, парсинг страниц
|
||||||
|
presentation/
|
||||||
|
tui/ # TUI интерфейс
|
||||||
|
cmd/ # Точка входа приложения
|
||||||
|
README.md # Это описание
|
||||||
|
Makefile # Команды сборки/тестов
|
||||||
|
config.yaml(.example) # Конфигурация
|
||||||
|
```
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
Требуется Go 1.21+.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Клонируйте репозиторий и перейдите в папку проекта
|
||||||
|
# Установите зависимости
|
||||||
|
make deps
|
||||||
|
# Сборка
|
||||||
|
make build
|
||||||
|
# Запуск
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
Если не используете Makefile, можно собрать так:
|
||||||
|
```powershell
|
||||||
|
$env:GOOS='windows'; $env:GOARCH='amd64'; $env:CGO_ENABLED='1'; go build -o audio-catalyst.exe ./cmd
|
||||||
|
./audio-catalyst.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
Файл `config.yaml`:
|
||||||
|
```yaml
|
||||||
|
scanner:
|
||||||
|
source_directory: "./audiobooks"
|
||||||
|
target_directory: "./organized"
|
||||||
|
|
||||||
|
rutracker:
|
||||||
|
base_url: "https://rutracker.org/forum/tracker.php"
|
||||||
|
user_agent: "Mozilla/5.0 ..."
|
||||||
|
request_delay: 2000
|
||||||
|
username: "<login>"
|
||||||
|
password: "<password>"
|
||||||
|
|
||||||
|
processing:
|
||||||
|
parallel_workers: 3
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry_attempts: 3
|
||||||
|
|
||||||
|
output:
|
||||||
|
log_level: "debug"
|
||||||
|
progress_bar: true
|
||||||
|
log_to_file: true
|
||||||
|
log_file_name: "audio-catalyst.log"
|
||||||
|
log_max_size_mb: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
- `scanner.source_directory` — директория с исходными аудиокнигами.
|
||||||
|
- `scanner.target_directory` — корень организованной библиотеки.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
- Запустите приложение `audio-catalyst.exe`.
|
||||||
|
- В TUI выберите «Начать обработку аудиокниг».
|
||||||
|
|
||||||
|
Процесс:
|
||||||
|
1) Сканирование `source_directory` — поиск папок, где есть MP3.
|
||||||
|
2) Поиск на RuTracker по имени папки, очистка названия.
|
||||||
|
3) Загрузка страницы темы, парсинг метаданных.
|
||||||
|
4) Формирование `metadata.json` и загрузка `cover.*`.
|
||||||
|
5) Переименование папки по Subtitle (без хвостов `RuTracker.org`).
|
||||||
|
6) Перенос в `target_directory/Буква/Автор/`.
|
||||||
|
|
||||||
|
Если книга не найдена на RuTracker — папка пропускается.
|
||||||
|
|
||||||
|
## Правила парсинга
|
||||||
|
- Title: из `<span style="font-size: 24px">`, затем нормализация (снятие префиксов автора, хвостов в скобках).
|
||||||
|
- Subtitle: полный `<title>`, отрезаются окончания типа `:: RuTracker.org`.
|
||||||
|
- Series: ищется в post-b «Серия», а также по шаблонам «Цикл «…»».
|
||||||
|
- Tags: текст первого подходящего `viewforum.php` (снятие префикса `[Аудио]`).
|
||||||
|
- Description: текст после «Описание» до `post-br` или следующего «post-b»; декодирование HTML-сущностей.
|
||||||
|
- Cover: `<a:has(img)>`, `<var.postImg title=...>`, `img data-*|src`, `og:image`.
|
||||||
|
|
||||||
|
## Организация библиотеки
|
||||||
|
После сохранения метаданных и обложки:
|
||||||
|
- Папка переименовывается в Subtitle.
|
||||||
|
- Переносится в `target_directory/<Первая буква автора>/<Фамилия Имя>/<Subtitle>`.
|
||||||
|
- Имена очищаются от недопустимых для Windows символов и хвостов `RuTracker.org`.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
```powershell
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
Покрыты тестами:
|
||||||
|
- domain/services: главы, очистка титулов, парсинг заголовков/серии/тегов.
|
||||||
|
- infrastructure/rutracker: нормализация URL.
|
||||||
|
- infrastructure/filesystem: поиск MP3, переименование и организация папок.
|
||||||
|
- application/usecases: сквозные сценарии (пропуск, успешный путь).
|
||||||
|
|
||||||
|
Покрытие:
|
||||||
|
```powershell
|
||||||
|
make coverage
|
||||||
|
start ./coverage.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
Основные команды:
|
||||||
|
- `make deps` — зависимости
|
||||||
|
- `make build` — сборка
|
||||||
|
- `make run` — запуск
|
||||||
|
- `make test` — тесты
|
||||||
|
- `make coverage` — покрытие
|
||||||
|
- `make fmt` / `make vet` / `make lint`
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
- Clean Architecture: слои domain/application/infrastructure/presentation.
|
||||||
|
- Взаимодействие через интерфейсы репозиториев.
|
||||||
|
- Use case `ProcessAudioBooksUseCase` оркестрирует процесс:
|
||||||
|
- ScanDirectory → RuTracker.Login → Search → GetTopicMetadata → CreateChapters → DownloadCover → SaveMetadata → Rename → Organize.
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
- Оценка длительности глав приблизительная (по размеру файла).
|
||||||
|
- Возможны изменения разметки RuTracker — парсер может потребовать корректировок.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
MIT
|
||||||
|
|||||||
138
cmd/main.go
Normal file
138
cmd/main.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"audio-catalyst/internal/application/usecases"
|
||||||
|
"audio-catalyst/internal/domain/entities"
|
||||||
|
"audio-catalyst/internal/domain/repositories"
|
||||||
|
"audio-catalyst/internal/infrastructure/config"
|
||||||
|
"audio-catalyst/internal/infrastructure/filesystem"
|
||||||
|
"audio-catalyst/internal/infrastructure/logging"
|
||||||
|
"audio-catalyst/internal/infrastructure/rutracker"
|
||||||
|
"audio-catalyst/internal/presentation/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Загрузка конфигурации
|
||||||
|
configRepo := config.NewRepository()
|
||||||
|
appConfig, err := configRepo.Load("config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Ошибка загрузки конфигурации: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация базового логгера (в файл)
|
||||||
|
fileLogger, err := logging.NewFileLogger(
|
||||||
|
appConfig.Output.LogFileName,
|
||||||
|
appConfig.Output.LogLevel,
|
||||||
|
appConfig.Output.LogMaxSizeMB,
|
||||||
|
appConfig.Output.LogToFile,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Предупреждение: не удалось инициализировать логгер: %v", err)
|
||||||
|
}
|
||||||
|
if fileLogger != nil {
|
||||||
|
defer fileLogger.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация TUI
|
||||||
|
tuiManager := tui.NewManager()
|
||||||
|
tuiManager.Initialize()
|
||||||
|
|
||||||
|
// Оборачиваем логгер адаптером, чтобы видеть логи в TUI
|
||||||
|
var logger repositories.Logger
|
||||||
|
logger = tui.NewUILogger(fileLogger, tuiManager)
|
||||||
|
|
||||||
|
// Инициализация репозиториев
|
||||||
|
fsRepo := filesystem.NewRepository()
|
||||||
|
rutrackerRepo, err := rutracker.NewRepository(
|
||||||
|
appConfig.RuTracker.Username,
|
||||||
|
appConfig.RuTracker.Password,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Ошибка создания RuTracker репозитория: %v", err)
|
||||||
|
}
|
||||||
|
defer rutrackerRepo.Close()
|
||||||
|
|
||||||
|
// Инициализация use case
|
||||||
|
processUseCase := usecases.NewProcessAudioBooksUseCase(
|
||||||
|
fsRepo,
|
||||||
|
rutrackerRepo,
|
||||||
|
logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Создание процессора для обработки команд
|
||||||
|
processor := &ApplicationProcessor{
|
||||||
|
processUseCase: processUseCase,
|
||||||
|
config: appConfig,
|
||||||
|
tuiManager: tuiManager,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Привязываем запуск обработки к TUI
|
||||||
|
tuiManager.SetOnStartProcessing(func() {
|
||||||
|
processor.StartProcessing()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Запуск обработчика команд в горутине
|
||||||
|
go processor.HandleCommands()
|
||||||
|
|
||||||
|
// Запуск TUI
|
||||||
|
if err := tuiManager.Run(); err != nil {
|
||||||
|
log.Fatalf("Ошибка запуска TUI: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationProcessor обрабатывает команды приложения
|
||||||
|
type ApplicationProcessor struct {
|
||||||
|
processUseCase *usecases.ProcessAudioBooksUseCase
|
||||||
|
config *entities.Config
|
||||||
|
tuiManager *tui.Manager
|
||||||
|
logger repositories.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCommands обрабатывает команды от пользователя
|
||||||
|
func (p *ApplicationProcessor) HandleCommands() {
|
||||||
|
// Пока оставим пустым — команды приходят через UI callbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartProcessing запускает обработку аудиокниг
|
||||||
|
func (p *ApplicationProcessor) StartProcessing() {
|
||||||
|
if p.logger != nil {
|
||||||
|
p.logger.Info("Запуск обработки аудиокниг...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновим UI статус
|
||||||
|
p.tuiManager.SendStatusUpdate(entities.ProcessingStatus{
|
||||||
|
Current: 0,
|
||||||
|
Total: 1,
|
||||||
|
Status: "Начинаем обработку...",
|
||||||
|
Error: nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Запускаем обработку
|
||||||
|
if err := p.processUseCase.Execute(p.config); err != nil {
|
||||||
|
if p.logger != nil {
|
||||||
|
p.logger.Error("Ошибка обработки: %v", err)
|
||||||
|
}
|
||||||
|
p.tuiManager.SendStatusUpdate(entities.ProcessingStatus{
|
||||||
|
Current: 0,
|
||||||
|
Total: 1,
|
||||||
|
Status: "Ошибка обработки",
|
||||||
|
Error: err,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Успешное завершение
|
||||||
|
p.tuiManager.SendStatusUpdate(entities.ProcessingStatus{
|
||||||
|
Current: 1,
|
||||||
|
Total: 1,
|
||||||
|
Status: "Обработка завершена успешно!",
|
||||||
|
Error: nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
if p.logger != nil {
|
||||||
|
p.logger.Success("Обработка аудиокниг завершена успешно")
|
||||||
|
}
|
||||||
|
}
|
||||||
638
cmd/main_test.go
Normal file
638
cmd/main_test.go
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
// Legacy tests for monolithic cmd package are ignored after refactor to Clean Architecture.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCleanSearchTitle тестирует функцию очистки названий
|
||||||
|
func TestCleanSearchTitle(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Удаление круглых скобок",
|
||||||
|
input: "Название книги (автор)",
|
||||||
|
expected: "Название книги",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Удаление квадратных скобок",
|
||||||
|
input: "Название книги [жанр]",
|
||||||
|
expected: "Название книги",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Удаление множественных пробелов",
|
||||||
|
input: "Название книги",
|
||||||
|
expected: "Название книги",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Комплексная очистка",
|
||||||
|
input: "Название книги (автор) [жанр] ",
|
||||||
|
expected: "Название книги",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Пустая строка",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := cleanSearchTitle(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("cleanSearchTitle(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindMP3Files тестирует поиск MP3 файлов
|
||||||
|
func TestFindMP3Files(t *testing.T) {
|
||||||
|
// Создаем временную директорию для теста
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Создаем тестовые файлы
|
||||||
|
testFiles := []string{
|
||||||
|
"test1.mp3",
|
||||||
|
"test2.MP3",
|
||||||
|
"test3.txt",
|
||||||
|
"test4.mp3",
|
||||||
|
"notmp3.wav",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range testFiles {
|
||||||
|
filePath := filepath.Join(tmpDir, file)
|
||||||
|
f, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Не удалось создать тестовый файл %s: %v", file, err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тестируем функцию
|
||||||
|
mp3Files, err := findMP3Files(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findMP3Files вернула ошибку: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем результат
|
||||||
|
expectedCount := 3 // test1.mp3, test2.MP3, test4.mp3
|
||||||
|
if len(mp3Files) != expectedCount {
|
||||||
|
t.Errorf("Ожидалось %d MP3 файлов, найдено %d", expectedCount, len(mp3Files))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что найдены правильные файлы
|
||||||
|
foundFiles := make(map[string]bool)
|
||||||
|
for _, file := range mp3Files {
|
||||||
|
foundFiles[filepath.Base(file)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedFiles := []string{"test1.mp3", "test2.MP3", "test4.mp3"}
|
||||||
|
for _, expected := range expectedFiles {
|
||||||
|
if !foundFiles[expected] {
|
||||||
|
t.Errorf("Файл %s не найден в результатах", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateChapters тестирует создание глав из MP3 файлов
|
||||||
|
func TestCreateChapters(t *testing.T) {
|
||||||
|
// Создаем временную директорию с тестовыми MP3 файлами
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Создаем тестовые MP3 файлы
|
||||||
|
testFiles := []string{
|
||||||
|
"01 - Введение.mp3",
|
||||||
|
"02 - Первая глава.mp3",
|
||||||
|
"03 - Вторая глава.mp3",
|
||||||
|
"10 - Заключение.mp3",
|
||||||
|
}
|
||||||
|
|
||||||
|
var mp3FilePaths []string
|
||||||
|
for _, filename := range testFiles {
|
||||||
|
filePath := filepath.Join(tempDir, filename)
|
||||||
|
// Создаем пустой файл
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Не удалось создать тестовый файл %s: %v", filename, err)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
mp3FilePaths = append(mp3FilePaths, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тестируем создание глав
|
||||||
|
chapters := createChapters(mp3FilePaths)
|
||||||
|
|
||||||
|
// Проверяем количество созданных глав
|
||||||
|
if len(chapters) != len(testFiles) {
|
||||||
|
t.Errorf("Ожидалось %d глав, получено %d", len(testFiles), len(chapters))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем первую главу
|
||||||
|
if chapters[0].ID != 0 {
|
||||||
|
t.Errorf("Ожидался ID 0 для первой главы, получен %d", chapters[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chapters[0].Title != "01 - Введение" {
|
||||||
|
t.Errorf("Ожидалось название '01 - Введение', получено '%s'", chapters[0].Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем последнюю главу
|
||||||
|
lastChapter := chapters[len(chapters)-1]
|
||||||
|
if lastChapter.ID != len(chapters)-1 {
|
||||||
|
t.Errorf("Ожидался ID %d для последней главы, получен %d", len(chapters)-1, lastChapter.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что главы отсортированы правильно
|
||||||
|
expectedTitles := []string{"01 - Введение", "02 - Первая глава", "03 - Вторая глава", "10 - Заключение"}
|
||||||
|
for i, expectedTitle := range expectedTitles {
|
||||||
|
if chapters[i].Title != expectedTitle {
|
||||||
|
t.Errorf("Глава %d: ожидалось название '%s', получено '%s'", i, expectedTitle, chapters[i].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindCoverFile тестирует поиск файлов обложек
|
||||||
|
func TestFindCoverFile(t *testing.T) {
|
||||||
|
// Создаем временную директорию для теста
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Создаем тестовые файлы
|
||||||
|
testFiles := []string{
|
||||||
|
"cover.jpg",
|
||||||
|
"other.txt",
|
||||||
|
"music.mp3",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range testFiles {
|
||||||
|
filePath := filepath.Join(tmpDir, file)
|
||||||
|
f, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Не удалось создать тестовый файл %s: %v", file, err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тестируем функцию
|
||||||
|
coverFile := findCoverFile(tmpDir)
|
||||||
|
expectedPath := filepath.Join(tmpDir, "cover.jpg")
|
||||||
|
|
||||||
|
if coverFile != expectedPath {
|
||||||
|
t.Errorf("Ожидался путь к обложке %q, получен %q", expectedPath, coverFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindCoverFileNotFound тестирует случай, когда обложка не найдена
|
||||||
|
func TestFindCoverFileNotFound(t *testing.T) {
|
||||||
|
// Создаем временную директорию без файлов обложек
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Создаем файлы, которые не являются обложками
|
||||||
|
testFiles := []string{
|
||||||
|
"music.mp3",
|
||||||
|
"readme.txt",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range testFiles {
|
||||||
|
filePath := filepath.Join(tmpDir, file)
|
||||||
|
f, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Не удалось создать тестовый файл %s: %v", file, err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тестируем функцию
|
||||||
|
coverFile := findCoverFile(tmpDir)
|
||||||
|
|
||||||
|
if coverFile != "" {
|
||||||
|
t.Errorf("Ожидалась пустая строка, получен %q", coverFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkCleanSearchTitle тестирует производительность очистки названий
|
||||||
|
func BenchmarkCleanSearchTitle(b *testing.B) {
|
||||||
|
testTitle := "Очень длинное название книги с (автором) [жанром] и множественными пробелами"
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
cleanSearchTitle(testTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateMetadata тестирует создание метаданных
|
||||||
|
func TestCreateMetadata(t *testing.T) {
|
||||||
|
// Подготавливаем тестовые данные
|
||||||
|
book := AudioBookInfo{
|
||||||
|
Title: "Тестовая книга",
|
||||||
|
Path: "/test/path",
|
||||||
|
MP3Files: []string{"chapter1.mp3", "chapter2.mp3"},
|
||||||
|
Description: "Описание тестовой книги",
|
||||||
|
}
|
||||||
|
|
||||||
|
rutrackerData := &RuTrackerResult{
|
||||||
|
Title: "RuTracker название",
|
||||||
|
Authors: []string{"Автор 1", "Автор 2"},
|
||||||
|
Narrators: []string{"Рассказчик 1"},
|
||||||
|
Genres: []string{"Фантастика", "Приключения"},
|
||||||
|
Description: "RuTracker описание",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тестируем функцию
|
||||||
|
metadata := createMetadata(book, rutrackerData)
|
||||||
|
|
||||||
|
// Проверяем результат
|
||||||
|
if metadata.Title != rutrackerData.Title {
|
||||||
|
t.Errorf("Ожидалось название %q, получено %q", rutrackerData.Title, metadata.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(metadata.Authors) != len(rutrackerData.Authors) {
|
||||||
|
t.Errorf("Ожидалось %d авторов, получено %d", len(rutrackerData.Authors), len(metadata.Authors))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(metadata.Chapters) != len(book.MP3Files) {
|
||||||
|
t.Errorf("Ожидалось %d глав, получено %d", len(book.MP3Files), len(metadata.Chapters))
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Language != "ru" {
|
||||||
|
t.Errorf("Ожидался язык 'ru', получен %q", metadata.Language)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseSearchResults тестирует функцию парсинга результатов поиска RuTracker
|
||||||
|
func TestParseSearchResults(t *testing.T) {
|
||||||
|
// Пример HTML контента с результатами поиска (упрощенная версия)
|
||||||
|
htmlContent := `
|
||||||
|
<table>
|
||||||
|
<tr class="tCenter">
|
||||||
|
<td><a href="dl.php?t=123456">Download</a></td>
|
||||||
|
<td><a href="viewtopic.php?t=123456">Тестовое название торрента</a></td>
|
||||||
|
<td class="tor-size">1.2 GB</td>
|
||||||
|
<td class="seedmed">15</td>
|
||||||
|
<td class="leechmed">3</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="tCenter">
|
||||||
|
<td><a href="dl.php?t=789012">Download</a></td>
|
||||||
|
<td><a href="viewtopic.php?t=789012">Другой торрент</a></td>
|
||||||
|
<td class="tor-size">2.5 GB</td>
|
||||||
|
<td class="seedmed">8</td>
|
||||||
|
<td class="leechmed">1</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
|
||||||
|
torrents, err := parseSearchResults(htmlContent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ошибка парсинга: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем количество найденных торрентов
|
||||||
|
expectedCount := 2
|
||||||
|
if len(torrents) != expectedCount {
|
||||||
|
t.Errorf("Ожидалось %d торрентов, найдено %d", expectedCount, len(torrents))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что найдены торренты с правильными ID
|
||||||
|
foundIDs := make(map[string]bool)
|
||||||
|
for _, torrent := range torrents {
|
||||||
|
foundIDs[torrent.ID] = true
|
||||||
|
|
||||||
|
// Проверяем, что URL формируются правильно
|
||||||
|
expectedTopicURL := "https://rutracker.org/forum/viewtopic.php?t=" + torrent.ID
|
||||||
|
if torrent.TopicURL != expectedTopicURL {
|
||||||
|
t.Errorf("Неправильный URL темы для ID %s: ожидался %q, получен %q",
|
||||||
|
torrent.ID, expectedTopicURL, torrent.TopicURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedDownloadURL := "https://rutracker.org/forum/dl.php?t=" + torrent.ID
|
||||||
|
if torrent.DownloadURL != expectedDownloadURL {
|
||||||
|
t.Errorf("Неправильный URL скачивания для ID %s: ожидался %q, получен %q",
|
||||||
|
torrent.ID, expectedDownloadURL, torrent.DownloadURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем наличие ожидаемых ID
|
||||||
|
expectedIDs := []string{"123456", "789012"}
|
||||||
|
for _, expectedID := range expectedIDs {
|
||||||
|
if !foundIDs[expectedID] {
|
||||||
|
t.Errorf("Не найден торрент с ID %s", expectedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseTopicMetadata тестирует функцию парсинга метаданных со страницы темы
|
||||||
|
func TestParseTopicMetadata(t *testing.T) {
|
||||||
|
// Реальный пример HTML контента страницы темы RuTracker с post-b структурой
|
||||||
|
htmlContent := `
|
||||||
|
<html>
|
||||||
|
<div class="post_body">
|
||||||
|
<span class="post-b">Год выпуска</span>: 2025<br>
|
||||||
|
<span class="post-b">Фамилия автора</span>: Первухин<br>
|
||||||
|
<span class="post-b">Имя автора</span>: Андрей<br>
|
||||||
|
<span class="post-b">Исполнитель</span>: Парфенов Константин<br>
|
||||||
|
<span class="post-b">Жанр</span>: Боевое фэнтези<br>
|
||||||
|
<span class="post-b">Издательство</span>: ЛитРес<br>
|
||||||
|
<span class="post-b">Описание</span>: Простой парень попал в магический мир и стал наследником рода.<br>
|
||||||
|
</div>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
torrent := TorrentInfo{
|
||||||
|
ID: "6643935",
|
||||||
|
Title: "Первухин Андрей - Наследник. Книга 03",
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := parseTopicMetadata(htmlContent, torrent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ошибка парсинга метаданных: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем извлеченные данные
|
||||||
|
if metadata.Title != torrent.Title {
|
||||||
|
t.Errorf("Неправильное название: ожидалось %q, получено %q", torrent.Title, metadata.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем автора (должен быть объединен из фамилии и имени)
|
||||||
|
expectedAuthor := "Первухин Андрей"
|
||||||
|
if len(metadata.Authors) != 1 || metadata.Authors[0] != expectedAuthor {
|
||||||
|
t.Errorf("Неправильные авторы: ожидался ['%s'], получено %v", expectedAuthor, metadata.Authors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем исполнителя
|
||||||
|
expectedNarrator := "Парфенов Константин"
|
||||||
|
if len(metadata.Narrators) != 1 || metadata.Narrators[0] != expectedNarrator {
|
||||||
|
t.Errorf("Неправильные чтецы: ожидался ['%s'], получено %v", expectedNarrator, metadata.Narrators)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем год
|
||||||
|
expectedYear := 2025
|
||||||
|
if metadata.PublishedYear == nil || *metadata.PublishedYear != expectedYear {
|
||||||
|
t.Errorf("Неправильный год: ожидался %d, получено %v", expectedYear, metadata.PublishedYear)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем издательство
|
||||||
|
expectedPublisher := "ЛитРес"
|
||||||
|
if metadata.Publisher == nil || *metadata.Publisher != expectedPublisher {
|
||||||
|
t.Errorf("Неправильное издательство: ожидалось '%s', получено %v", expectedPublisher, metadata.Publisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем жанры
|
||||||
|
expectedGenres := []string{"Боевое фэнтези"}
|
||||||
|
if len(metadata.Genres) != 1 || metadata.Genres[0] != expectedGenres[0] {
|
||||||
|
t.Errorf("Неправильные жанры: ожидались %v, получено %v", expectedGenres, metadata.Genres)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем описание
|
||||||
|
expectedDesc := "Простой парень попал в магический мир и стал наследником рода."
|
||||||
|
if metadata.Description != expectedDesc {
|
||||||
|
t.Errorf("Неправильное описание: ожидалось '%s', получено '%s'", expectedDesc, metadata.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Language != "ru" {
|
||||||
|
t.Errorf("Неправильный язык: ожидался 'ru', получен %q", metadata.Language)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseTopicMetadataRealHTML тестирует парсинг с реальным HTML от RuTracker
|
||||||
|
func TestParseTopicMetadataRealHTML(t *testing.T) {
|
||||||
|
// Реальный фрагмент HTML со страницы RuTracker
|
||||||
|
htmlContent := `<div class="post_body" id="p-87376820">
|
||||||
|
<span style="font-size: 24px; line-height: normal;">Наследник. Книга 03</span> <span class="post-br"><br></span>
|
||||||
|
<var class="postImg postImgAligned img-right" title="https://i124.fastpic.org/big/2025/0209/7a/d953d7a9b39477e7da76e222993b1f7a.jpg"> </var>
|
||||||
|
<span class="post-b">Год выпуска</span>: 2025<br>
|
||||||
|
<span class="post-b">Фамилия автора</span>: Первухин<br>
|
||||||
|
<span class="post-b">Имя автора</span>: Андрей<br>
|
||||||
|
<span class="post-b">Исполнитель</span>: Парфенов Константин<br>
|
||||||
|
<span class="post-b">Жанр</span>: Боевое фэнтези<br>
|
||||||
|
<span class="post-b">Издательство</span>: ЛитРес: чтец , Автор<br>
|
||||||
|
<span class="post-b">Аудиокодек</span>: MP3<br>
|
||||||
|
<span class="post-b">Битрейт</span>: 96 kbps<br>
|
||||||
|
<span class="post-b">Вид битрейта</span>: постоянный битрейт (CBR)<br>
|
||||||
|
<span class="post-b">Частота дискретизации</span>: 32 kHz<br>
|
||||||
|
<span class="post-b">Количество каналов (моно-стерео)</span>: Стерео<br>
|
||||||
|
<span class="post-b">Время звучания</span>: 07:25:47<br>
|
||||||
|
<span class="post-b">Описание</span>: Простой парень, без каких-то особых талантов попал в магический мир. Вроде бы живи и радуйся, тем более, в новом мире ты не простой человек, а целый наследник рода, правда, не самого богатого и влиятельного. Только вот беда в том, что боги не обошли его своим вниманием.<span class="post-br"><br></span>
|
||||||
|
<a href="tracker.php?f=2387&nm=%CF%E5%F0%E2%F3%F5%E8%ED+%ED%E0%F1%EB%E5%E4%ED%E8%EA" class="postLink">Цикл «Наследник»</a><br>
|
||||||
|
01. Наследник. Книга 01<br>
|
||||||
|
02. Наследник. Книга 02<br>
|
||||||
|
03. Наследник. Книга 03<br>
|
||||||
|
03. Наследник. Книга 04
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
torrent := TorrentInfo{
|
||||||
|
ID: "6643935",
|
||||||
|
Title: "Первухин Андрей - Наследник. Книга 03 [Парфенов Константин, 2025, 96 kbps, MP3]",
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := parseTopicMetadata(htmlContent, torrent)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Ошибка парсинга реального HTML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем автора
|
||||||
|
expectedAuthor := "Первухин Андрей"
|
||||||
|
if len(metadata.Authors) != 1 || metadata.Authors[0] != expectedAuthor {
|
||||||
|
t.Errorf("Неправильные авторы: ожидался ['%s'], получено %v", expectedAuthor, metadata.Authors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем исполнителя
|
||||||
|
expectedNarrator := "Парфенов Константин"
|
||||||
|
if len(metadata.Narrators) != 1 || metadata.Narrators[0] != expectedNarrator {
|
||||||
|
t.Errorf("Неправильные чтецы: ожидался ['%s'], получено %v", expectedNarrator, metadata.Narrators)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем жанр
|
||||||
|
expectedGenre := "Боевое фэнтези"
|
||||||
|
if len(metadata.Genres) != 1 || metadata.Genres[0] != expectedGenre {
|
||||||
|
t.Errorf("Неправильные жанры: ожидался ['%s'], получено %v", expectedGenre, metadata.Genres)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем год
|
||||||
|
expectedYear := 2025
|
||||||
|
if metadata.PublishedYear == nil || *metadata.PublishedYear != expectedYear {
|
||||||
|
t.Errorf("Неправильный год: ожидался %d, получено %v", expectedYear, metadata.PublishedYear)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем издательство (должно очистить ": чтец , Автор")
|
||||||
|
expectedPublisher := "ЛитРес"
|
||||||
|
if metadata.Publisher == nil || *metadata.Publisher != expectedPublisher {
|
||||||
|
t.Errorf("Неправильное издательство: ожидалось '%s', получено %v", expectedPublisher, metadata.Publisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем описание
|
||||||
|
if metadata.Description == "" {
|
||||||
|
t.Error("Описание не найдено")
|
||||||
|
} else if !strings.Contains(metadata.Description, "Простой парень") {
|
||||||
|
t.Errorf("Описание не содержит ожидаемый текст: %s", metadata.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем информацию о серии (должна найтись в ссылке)
|
||||||
|
if len(metadata.Series) == 0 {
|
||||||
|
t.Error("Серия не найдена")
|
||||||
|
} else if !strings.Contains(metadata.Series[0], "Наследник") {
|
||||||
|
t.Errorf("Неправильная серия: %v", metadata.Series)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Успешно извлечены метаданные: автор=%v, чтец=%v, жанр=%v, год=%v",
|
||||||
|
metadata.Authors, metadata.Narrators, metadata.Genres, *metadata.PublishedYear)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractCoverURL тестирует извлечение URL обложки из HTML
|
||||||
|
func TestExtractCoverURL(t *testing.T) {
|
||||||
|
// HTML с var тегом как в реальном RuTracker
|
||||||
|
htmlWithVar := `<div class="post_body">
|
||||||
|
<var class="postImg postImgAligned img-right" title="https://i124.fastpic.org/big/2025/0209/7a/d953d7a9b39477e7da76e222993b1f7a.jpg"> </var>
|
||||||
|
Некоторый текст...
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
coverURL := extractCoverURL(htmlWithVar)
|
||||||
|
expectedURL := "https://i124.fastpic.org/big/2025/0209/7a/d953d7a9b39477e7da76e222993b1f7a.jpg"
|
||||||
|
|
||||||
|
if coverURL != expectedURL {
|
||||||
|
t.Errorf("Неправильный URL обложки: ожидался '%s', получен '%s'", expectedURL, coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML с обычным img тегом
|
||||||
|
htmlWithImg := `<div class="post_body">
|
||||||
|
<img src="https://example.com/cover.png" alt="Cover">
|
||||||
|
Текст...
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
coverURL2 := extractCoverURL(htmlWithImg)
|
||||||
|
expectedURL2 := "https://example.com/cover.png"
|
||||||
|
|
||||||
|
if coverURL2 != expectedURL2 {
|
||||||
|
t.Errorf("Неправильный URL обложки из img: ожидался '%s', получен '%s'", expectedURL2, coverURL2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML без изображений
|
||||||
|
htmlEmpty := `<div class="post_body">
|
||||||
|
Только текст без изображений
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
coverURL3 := extractCoverURL(htmlEmpty)
|
||||||
|
if coverURL3 != "" {
|
||||||
|
t.Errorf("Ожидался пустой URL, получен '%s'", coverURL3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML с неправильным URL (не изображение)
|
||||||
|
htmlWrongURL := `<div class="post_body">
|
||||||
|
<var class="postImg" title="https://example.com/not-image.txt"> </var>
|
||||||
|
</div>`
|
||||||
|
|
||||||
|
coverURL4 := extractCoverURL(htmlWrongURL)
|
||||||
|
if coverURL4 != "" {
|
||||||
|
t.Errorf("Ожидалась пустая строка для не-изображения, получен '%s'", coverURL4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkParseSearchResults бенчмарк для функции парсинга результатов поиска
|
||||||
|
func BenchmarkParseSearchResults(b *testing.B) {
|
||||||
|
htmlContent := `
|
||||||
|
<table>
|
||||||
|
<tr class="tCenter">
|
||||||
|
<td><a href="dl.php?t=123456">Download</a></td>
|
||||||
|
<td><a href="viewtopic.php?t=123456">Тестовое название торрента</a></td>
|
||||||
|
<td class="tor-size">1.2 GB</td>
|
||||||
|
<td class="seedmed">15</td>
|
||||||
|
<td class="leechmed">3</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, err := parseSearchResults(htmlContent)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkParseTopicMetadata бенчмарк для функции парсинга метаданных страницы темы
|
||||||
|
func BenchmarkParseTopicMetadata(b *testing.B) {
|
||||||
|
htmlContent := `
|
||||||
|
<html>
|
||||||
|
<div class="post_body">
|
||||||
|
<span class="post-b">Год выпуска</span>: 2025<br>
|
||||||
|
<span class="post-b">Фамилия автора</span>: Первухин<br>
|
||||||
|
<span class="post-b">Имя автора</span>: Андрей<br>
|
||||||
|
<span class="post-b">Исполнитель</span>: Парфенов Константин<br>
|
||||||
|
<span class="post-b">Жанр</span>: Боевое фэнтези<br>
|
||||||
|
<span class="post-b">Описание</span>: Описание книги.
|
||||||
|
</div>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
torrent := TorrentInfo{
|
||||||
|
ID: "6643935",
|
||||||
|
Title: "Первухин Андрей - Наследник. Книга 03",
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_, err := parseTopicMetadata(htmlContent, torrent)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDownloadCoverValidation тестирует валидацию функции downloadCover
|
||||||
|
func TestDownloadCoverValidation(t *testing.T) {
|
||||||
|
// Создаем временную директорию для тестов
|
||||||
|
tempDir, err := os.MkdirTemp("", "cover_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Не удалось создать временную директорию: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Тест с пустым URL
|
||||||
|
err = downloadCover("", tempDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Ожидалась ошибка для пустого URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест с неправильным URL
|
||||||
|
err = downloadCover("not-a-url", tempDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Ожидалась ошибка для неправильного URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Примечание: полные HTTP-тесты требуют мокирования или реального сервера
|
||||||
|
// Для production тестирования можно использовать httptest.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractChapterTitle тестирует извлечение названий глав из имен файлов
|
||||||
|
func TestExtractChapterTitle(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"01 - Введение", "01 - Введение"},
|
||||||
|
{"001_Первая_глава", "001 - Первая_глава"},
|
||||||
|
{"Chapter 05 - Название", "05 - Название"},
|
||||||
|
{"Глава 3 - Важная информация", "3 - Важная информация"},
|
||||||
|
{"Часть 10", "10"},
|
||||||
|
{"простое_название", "простое_название"},
|
||||||
|
{"15", "15"},
|
||||||
|
{"chapter_1_test", "1 - test"},
|
||||||
|
{"глава_2_описание", "2 - описание"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
result := extractChapterTitle(tc.input)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("extractChapterTitle(%q) = %q, ожидалось %q", tc.input, result, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
config.yaml.example
Normal file
24
config.yaml.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Конфигурация AudioBook Catalyst
|
||||||
|
|
||||||
|
scanner:
|
||||||
|
source_directory: "D:\\AudioBooks\\Source"
|
||||||
|
target_directory: "D:\\AudioBooks\\Processed"
|
||||||
|
|
||||||
|
rutracker:
|
||||||
|
base_url: "https://rutracker.org/forum/tracker.php"
|
||||||
|
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
request_delay: 1000 # миллисекунды между запросами
|
||||||
|
username: "your_username_here" # Ваш логин на RuTracker
|
||||||
|
password: "your_password_here" # Ваш пароль на RuTracker
|
||||||
|
|
||||||
|
processing:
|
||||||
|
parallel_workers: 3
|
||||||
|
timeout_seconds: 30
|
||||||
|
retry_attempts: 3
|
||||||
|
|
||||||
|
output:
|
||||||
|
log_level: "debug" # debug, info, warning, error
|
||||||
|
progress_bar: true
|
||||||
|
log_to_file: true
|
||||||
|
log_file_name: "audio-catalyst.log"
|
||||||
|
log_max_size_mb: 10
|
||||||
25
go.mod
Normal file
25
go.mod
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
module audio-catalyst
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.8.1
|
||||||
|
github.com/fatih/color v1.18.0
|
||||||
|
github.com/gdamore/tcell/v2 v2.6.0
|
||||||
|
github.com/rivo/tview v0.0.0-20231126152417-33a1d271f2b6
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||||
|
github.com/gdamore/encoding v1.0.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.14 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.4 // indirect
|
||||||
|
golang.org/x/net v0.7.0 // indirect
|
||||||
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
|
golang.org/x/term v0.14.0 // indirect
|
||||||
|
golang.org/x/text v0.7.0 // indirect
|
||||||
|
)
|
||||||
67
go.sum
Normal file
67
go.sum
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||||
|
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||||
|
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||||
|
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||||
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
|
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||||
|
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||||
|
github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg=
|
||||||
|
github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/rivo/tview v0.0.0-20231126152417-33a1d271f2b6 h1:7UMY2qN9VlcY+x9jlhpYe5Bf1zrdhvmfZyLMk2u65BM=
|
||||||
|
github.com/rivo/tview v0.0.0-20231126152417-33a1d271f2b6/go.mod h1:nVwGv4MP47T0jvlk7KuTTjjuSmrGO4JF0iaiNt4bufE=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||||
|
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||||
|
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8=
|
||||||
|
golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
209
internal/application/usecases/process_audiobooks.go
Normal file
209
internal/application/usecases/process_audiobooks.go
Normal 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
|
||||||
|
}
|
||||||
70
internal/application/usecases/process_audiobooks_test.go
Normal file
70
internal/application/usecases/process_audiobooks_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
internal/domain/entities/audiobook.go
Normal file
41
internal/domain/entities/audiobook.go
Normal 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"` // в секундах
|
||||||
|
}
|
||||||
40
internal/domain/entities/config.go
Normal file
40
internal/domain/entities/config.go
Normal 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"`
|
||||||
|
}
|
||||||
19
internal/domain/entities/status.go
Normal file
19
internal/domain/entities/status.go
Normal 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
|
||||||
|
)
|
||||||
27
internal/domain/entities/torrent.go
Normal file
27
internal/domain/entities/torrent.go
Normal 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
|
||||||
|
}
|
||||||
21
internal/domain/repositories/audiobook_repository.go
Normal file
21
internal/domain/repositories/audiobook_repository.go
Normal 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)
|
||||||
|
}
|
||||||
12
internal/domain/repositories/config_repository.go
Normal file
12
internal/domain/repositories/config_repository.go
Normal 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
|
||||||
|
}
|
||||||
10
internal/domain/repositories/logger.go
Normal file
10
internal/domain/repositories/logger.go
Normal 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{})
|
||||||
|
}
|
||||||
21
internal/domain/repositories/rutracker_repository.go
Normal file
21
internal/domain/repositories/rutracker_repository.go
Normal 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()
|
||||||
|
}
|
||||||
132
internal/domain/services/audiobook_service.go
Normal file
132
internal/domain/services/audiobook_service.go
Normal 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
|
||||||
|
}
|
||||||
66
internal/domain/services/audiobook_service_test.go
Normal file
66
internal/domain/services/audiobook_service_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
520
internal/domain/services/metadata_service.go
Normal file
520
internal/domain/services/metadata_service.go
Normal 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{
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
""": "\"",
|
||||||
|
"'": "'",
|
||||||
|
" ": " ",
|
||||||
|
"'": "'",
|
||||||
|
""": "\"",
|
||||||
|
"«": "«",
|
||||||
|
"»": "»",
|
||||||
|
"–": "–",
|
||||||
|
"—": "—",
|
||||||
|
"…": "…",
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/domain/services/metadata_service_test.go
Normal file
42
internal/domain/services/metadata_service_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
internal/presentation/tui/logger_adapter.go
Normal file
57
internal/presentation/tui/logger_adapter.go
Normal 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)
|
||||||
|
}
|
||||||
343
internal/presentation/tui/manager.go
Normal file
343
internal/presentation/tui/manager.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user