1
1

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

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

View File

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

View File

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