From 72a66f1664b788075ec1ca06c34585640e5b9474 Mon Sep 17 00:00:00 2001 From: Dmitriy Fofanov Date: Mon, 29 Sep 2025 20:40:05 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=81=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=BC=D1=8B=20=D0=B8=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=84=D1=83=D0=BD=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы. - Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов. - Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов. - Добавить тесты для нормализации URL в репозиторий RuTracker. - Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер. - Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов. --- .gitignore | 49 +- ARCHITECTURE.md | 122 ++++ LICENSE | 23 + Makefile | 114 ++++ README.md | 151 ++++- cmd/main.go | 138 ++++ cmd/main_test.go | 638 ++++++++++++++++++ config.yaml.example | 24 + go.mod | 25 + go.sum | 67 ++ .../usecases/process_audiobooks.go | 209 ++++++ .../usecases/process_audiobooks_test.go | 70 ++ internal/domain/entities/audiobook.go | 41 ++ internal/domain/entities/config.go | 40 ++ internal/domain/entities/status.go | 19 + internal/domain/entities/torrent.go | 27 + .../repositories/audiobook_repository.go | 21 + .../domain/repositories/config_repository.go | 12 + internal/domain/repositories/logger.go | 10 + .../repositories/rutracker_repository.go | 21 + internal/domain/services/audiobook_service.go | 132 ++++ .../domain/services/audiobook_service_test.go | 66 ++ internal/domain/services/metadata_service.go | 520 ++++++++++++++ .../domain/services/metadata_service_test.go | 42 ++ internal/infrastructure/config/repository.go | 47 ++ .../infrastructure/filesystem/repository.go | 286 ++++++++ .../filesystem/repository_test.go | 53 ++ .../infrastructure/logging/file_logger.go | 185 +++++ .../infrastructure/rutracker/repository.go | 523 ++++++++++++++ .../rutracker/repository_test.go | 20 + internal/presentation/tui/logger_adapter.go | 57 ++ internal/presentation/tui/manager.go | 343 ++++++++++ 32 files changed, 4073 insertions(+), 22 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 cmd/main.go create mode 100644 cmd/main_test.go create mode 100644 config.yaml.example create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/application/usecases/process_audiobooks.go create mode 100644 internal/application/usecases/process_audiobooks_test.go create mode 100644 internal/domain/entities/audiobook.go create mode 100644 internal/domain/entities/config.go create mode 100644 internal/domain/entities/status.go create mode 100644 internal/domain/entities/torrent.go create mode 100644 internal/domain/repositories/audiobook_repository.go create mode 100644 internal/domain/repositories/config_repository.go create mode 100644 internal/domain/repositories/logger.go create mode 100644 internal/domain/repositories/rutracker_repository.go create mode 100644 internal/domain/services/audiobook_service.go create mode 100644 internal/domain/services/audiobook_service_test.go create mode 100644 internal/domain/services/metadata_service.go create mode 100644 internal/domain/services/metadata_service_test.go create mode 100644 internal/infrastructure/config/repository.go create mode 100644 internal/infrastructure/filesystem/repository.go create mode 100644 internal/infrastructure/filesystem/repository_test.go create mode 100644 internal/infrastructure/logging/file_logger.go create mode 100644 internal/infrastructure/rutracker/repository.go create mode 100644 internal/infrastructure/rutracker/repository_test.go create mode 100644 internal/presentation/tui/logger_adapter.go create mode 100644 internal/presentation/tui/manager.go diff --git a/.gitignore b/.gitignore index 5b90e79..7982774 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,34 @@ -# ---> Go -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins +# Примеры папок с аудиокнигами +audiobooks/ +cover.jpg +description.txt +*.log + +# Организованная библиотека +organized/ + +# Конфигурационные файлы +config.yaml + +# Исполняемые файлы *.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 +audio-catalyst -# Dependency directories (remove the comment below to include it) -# vendor/ +# Go временные файлы +*.tmp +*.swp +*.swo -# Go workspace file -go.work -go.work.sum +# IDE файлы +.vscode/ +.idea/ +*.iml -# env file -.env +# Системные файлы +.DS_Store +Thumbs.db +# Логи +logs/ +*.log diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..5333e74 --- /dev/null +++ b/ARCHITECTURE.md @@ -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. Удаление закомментированного кода после успешного тестирования diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6c2274e --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +Лицензия MIT + +Авторские права (c) 2025 AudioBook Catalyst + +Настоящим предоставляется бесплатное разрешение любому лицу, получившему копию +данного программного обеспечения и связанных с ним файлов документации +("Программное обеспечение"), использовать Программное обеспечение без ограничений, +включая неограниченное право на использование, копирование, изменение, слияние, +публикацию, распространение, сублицензирование и/или продажу копий Программного +обеспечения, а также лицам, которым предоставляется данное Программное +обеспечение, при соблюдении следующих условий: + +Указанное выше уведомление об авторском праве и данные условия должны быть +включены во все копии или значительные части данного Программного обеспечения. + +ДАННОЕ ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ», БЕЗ КАКИХ-ЛИБО +ГАРАНТИЙ, ЯВНО ВЫРАЖЕННЫХ ИЛИ ПОДРАЗУМЕВАЕМЫХ, ВКЛЮЧАЯ ГАРАНТИИ ТОВАРНОЙ +ПРИГОДНОСТИ, СООТВЕТСТВИЯ ПО ЕГО КОНКРЕТНОМУ НАЗНАЧЕНИЮ И ОТСУТСТВИЯ НАРУШЕНИЙ, +НО НЕ ОГРАНИЧИВАЯСЬ ИМИ. НИ В КАКОМ СЛУЧАЕ АВТОРЫ ИЛИ ПРАВООБЛАДАТЕЛИ НЕ НЕСУТ +ОТВЕТСТВЕННОСТИ ПО КАКИМ-ЛИБО ИСКАМ, ЗА УЩЕРБ ИЛИ ПО ИНЫМ ТРЕБОВАНИЯМ, В ТОМ +ЧИСЛЕ, ПРИ ДЕЙСТВИИ ДОГОВОРА, ДЕЛИКТЕ ИЛИ ИНОЙ СИТУАЦИИ, ВОЗНИКШИМ ИЗ-ЗА +ИСПОЛЬЗОВАНИЯ ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ ИЛИ ИНЫХ ДЕЙСТВИЙ С ПРОГРАММНЫМ +ОБЕСПЕЧЕНИЕМ. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d63d93a --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index e278b66..7b07217 100644 --- a/README.md +++ b/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. +- Корректный парсинг заголовка из `` и подзаголовка из `` без хвостов `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 diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..6ce7d28 --- /dev/null +++ b/cmd/main.go @@ -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("Обработка аудиокниг завершена успешно") + } +} diff --git a/cmd/main_test.go b/cmd/main_test.go new file mode 100644 index 0000000..e6739d5 --- /dev/null +++ b/cmd/main_test.go @@ -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) + } + } +} diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..1c76467 --- /dev/null +++ b/config.yaml.example @@ -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 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d77aed --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..41a76ee --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/application/usecases/process_audiobooks.go b/internal/application/usecases/process_audiobooks.go new file mode 100644 index 0000000..1bf3680 --- /dev/null +++ b/internal/application/usecases/process_audiobooks.go @@ -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 +} diff --git a/internal/application/usecases/process_audiobooks_test.go b/internal/application/usecases/process_audiobooks_test.go new file mode 100644 index 0000000..d14ccad --- /dev/null +++ b/internal/application/usecases/process_audiobooks_test.go @@ -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) + } +} diff --git a/internal/domain/entities/audiobook.go b/internal/domain/entities/audiobook.go new file mode 100644 index 0000000..a603d75 --- /dev/null +++ b/internal/domain/entities/audiobook.go @@ -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"` // в секундах +} diff --git a/internal/domain/entities/config.go b/internal/domain/entities/config.go new file mode 100644 index 0000000..1a6ffb9 --- /dev/null +++ b/internal/domain/entities/config.go @@ -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"` +} diff --git a/internal/domain/entities/status.go b/internal/domain/entities/status.go new file mode 100644 index 0000000..868bb43 --- /dev/null +++ b/internal/domain/entities/status.go @@ -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 +) diff --git a/internal/domain/entities/torrent.go b/internal/domain/entities/torrent.go new file mode 100644 index 0000000..de2f3ad --- /dev/null +++ b/internal/domain/entities/torrent.go @@ -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 +} diff --git a/internal/domain/repositories/audiobook_repository.go b/internal/domain/repositories/audiobook_repository.go new file mode 100644 index 0000000..373d4e2 --- /dev/null +++ b/internal/domain/repositories/audiobook_repository.go @@ -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) +} diff --git a/internal/domain/repositories/config_repository.go b/internal/domain/repositories/config_repository.go new file mode 100644 index 0000000..622bc7c --- /dev/null +++ b/internal/domain/repositories/config_repository.go @@ -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 +} diff --git a/internal/domain/repositories/logger.go b/internal/domain/repositories/logger.go new file mode 100644 index 0000000..600219d --- /dev/null +++ b/internal/domain/repositories/logger.go @@ -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{}) +} diff --git a/internal/domain/repositories/rutracker_repository.go b/internal/domain/repositories/rutracker_repository.go new file mode 100644 index 0000000..a40f03f --- /dev/null +++ b/internal/domain/repositories/rutracker_repository.go @@ -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() +} diff --git a/internal/domain/services/audiobook_service.go b/internal/domain/services/audiobook_service.go new file mode 100644 index 0000000..ba93091 --- /dev/null +++ b/internal/domain/services/audiobook_service.go @@ -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 +} diff --git a/internal/domain/services/audiobook_service_test.go b/internal/domain/services/audiobook_service_test.go new file mode 100644 index 0000000..6ab4538 --- /dev/null +++ b/internal/domain/services/audiobook_service_test.go @@ -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) + } +} diff --git a/internal/domain/services/metadata_service.go b/internal/domain/services/metadata_service.go new file mode 100644 index 0000000..f267f17 --- /dev/null +++ b/internal/domain/services/metadata_service.go @@ -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[^>]*>(.*?)`).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 извлекает содержимое тега без агрессивной нормализации +func (s *MetadataService) extractPageTitle(html string) string { + re := regexp.MustCompile(`(?is)<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)]+href=["'][^"']*viewforum\\.php\?f=\d+[^"']*["'][^>]*>\s*(.*?)\s*`) + 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} + } +} diff --git a/internal/domain/services/metadata_service_test.go b/internal/domain/services/metadata_service_test.go new file mode 100644 index 0000000..baf4656 --- /dev/null +++ b/internal/domain/services/metadata_service_test.go @@ -0,0 +1,42 @@ +package services + +import ( + "testing" + + "audio-catalyst/internal/domain/entities" +) + +func TestExtractHTMLTitleAndSubtitle(t *testing.T) { + s := NewMetadataService() + html := `Автор - Полный тайтл :: RuTracker.org +
Наследник. Книга 03
` + 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 := `
+ [Аудио] Российская фантастика, фэнтези, мистика, ужасы, фанфики + Цикл «Наследник» + Описание: Текст +
` + 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) + } +} diff --git a/internal/infrastructure/config/repository.go b/internal/infrastructure/config/repository.go new file mode 100644 index 0000000..4734638 --- /dev/null +++ b/internal/infrastructure/config/repository.go @@ -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 +} diff --git a/internal/infrastructure/filesystem/repository.go b/internal/infrastructure/filesystem/repository.go new file mode 100644 index 0000000..431bbb0 --- /dev/null +++ b/internal/infrastructure/filesystem/repository.go @@ -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// +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 +} diff --git a/internal/infrastructure/filesystem/repository_test.go b/internal/infrastructure/filesystem/repository_test.go new file mode 100644 index 0000000..d13b642 --- /dev/null +++ b/internal/infrastructure/filesystem/repository_test.go @@ -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) + } +} diff --git a/internal/infrastructure/logging/file_logger.go b/internal/infrastructure/logging/file_logger.go new file mode 100644 index 0000000..bfa9205 --- /dev/null +++ b/internal/infrastructure/logging/file_logger.go @@ -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() +} diff --git a/internal/infrastructure/rutracker/repository.go b/internal/infrastructure/rutracker/repository.go new file mode 100644 index 0000000..a25cad5 --- /dev/null +++ b/internal/infrastructure/rutracker/repository.go @@ -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: (содержит прямую ссылку) + 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)]+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)]+class=["'][^"']*postImg[^"']*["'][^>]*>([^<\s][^<]+)` + "").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)]+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)]+(?: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)]+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("]*href="[^"]*viewtopic\.php\?t=(\d+)"[^>]*>(.*?)`) + 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 "" +} diff --git a/internal/infrastructure/rutracker/repository_test.go b/internal/infrastructure/rutracker/repository_test.go new file mode 100644 index 0000000..9c20eaa --- /dev/null +++ b/internal/infrastructure/rutracker/repository_test.go @@ -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) + } + } +} diff --git a/internal/presentation/tui/logger_adapter.go b/internal/presentation/tui/logger_adapter.go new file mode 100644 index 0000000..07333d2 --- /dev/null +++ b/internal/presentation/tui/logger_adapter.go @@ -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) +} diff --git a/internal/presentation/tui/manager.go b/internal/presentation/tui/manager.go new file mode 100644 index 0000000..fd987ea --- /dev/null +++ b/internal/presentation/tui/manager.go @@ -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) +}