Функция: реализованы консольный логгер и презентер для обработки аудиокниг

- Добавлен ConsoleLogger для подробного логирования этапов обработки аудиокниг в консоли.

- Введен ConsolePresenter для форматированного вывода результатов сканирования в консоль.

- Создан ProcessAudioBooksUseCase для обработки полного конвейера обработки аудиокниг, включая сканирование папок, извлечение метаданных, поиск торрентов и запись результатов.

- Реализована проверка LLM для улучшения метаданных.

- Добавлена ​​обработка ошибок и логирование на всех этапах обработки.
This commit is contained in:
Dmitriy Fofanov
2026-02-20 00:35:43 +03:00
parent 7d119927a1
commit 402ce7f4f1
26 changed files with 4323 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
// Package main — точка входа. Собирает зависимости (Composition Root)
// и запускает полный конвейер обработки аудиокниг.
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/fofanov/genaudiobookinfo/internal/domain"
"github.com/fofanov/genaudiobookinfo/internal/infrastructure"
"github.com/fofanov/genaudiobookinfo/internal/presentation"
"github.com/fofanov/genaudiobookinfo/internal/usecase"
"gopkg.in/yaml.v3"
)
var version = "2.0.0"
type appConfig struct {
Dir struct {
In string `yaml:"in"`
Out string `yaml:"out"`
} `yaml:"dir"`
InDir string `yaml:"in_dir"`
OutDir string `yaml:"out_dir"`
TorrAPI struct {
URL string `yaml:"url"`
} `yaml:"torrapi"`
OpenRouter struct {
APIKey string `yaml:"api_key"`
BaseURL string `yaml:"base_url"`
Timeout time.Duration `yaml:"timeout"`
Model string `yaml:"model"`
Prompt string `yaml:"prompt"`
MaxRetries int `yaml:"max_retries"`
RetryBackoff time.Duration `yaml:"retry_backoff"`
RetryBackoffMax time.Duration `yaml:"retry_backoff_max"`
} `yaml:"openrouter"`
}
func main() {
// Устанавливаем UTF-8 кодировку для консоли Windows (исправляет кракозябры с кириллицей)
infrastructure.SetConsoleUTF8()
// --- CLI-флаги ---
workers := flag.Int("workers", 2, "количество параллельных воркеров")
timeout := flag.Duration("timeout", 5*time.Minute, "таймаут обработки")
apiURL := flag.String("api", "", "адрес TorrAPI сервера (переопределяет config.yaml)")
resultDir := flag.String("result", "", "каталог для результатов (по умолчанию: <входная папка>/result)")
showVersion := flag.Bool("version", false, "показать версию")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Использование: %s [опции] [путь к каталогу с аудиокнигами]\n\n", os.Args[0])
fmt.Fprintln(os.Stderr, "Сканирует каталог, находит аудиокниги, обогащает данными с трекеров,")
fmt.Fprintln(os.Stderr, "создаёт metadata.json и переносит файлы в структурированные папки.")
fmt.Fprintln(os.Stderr, "Если путь не передан, используется dir.in из config.yaml.")
fmt.Fprintln(os.Stderr, "Опции:")
flag.PrintDefaults()
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Пример:")
fmt.Fprintf(os.Stderr, " %s -workers 4 -api http://localhost:9200 D:\\Audiobooks\n", os.Args[0])
}
flag.Parse()
cfg := loadConfig("config.yaml")
if *showVersion {
fmt.Printf("GenAudioBookInfo v%s\n", version)
os.Exit(0)
}
rootPath := ""
if flag.NArg() >= 1 {
rootPath = flag.Arg(0)
} else {
rootPath = cfg.Dir.In
if rootPath == "" {
rootPath = cfg.InDir
}
}
if rootPath == "" {
flag.Usage()
fmt.Fprintln(os.Stderr, "Ошибка: не задан входной каталог (укажите аргумент или dir.in в config.yaml).")
os.Exit(1)
}
effectiveAPIURL := cfg.TorrAPI.URL
if effectiveAPIURL == "" {
effectiveAPIURL = "http://localhost:9200"
}
if *apiURL != "" {
effectiveAPIURL = *apiURL
}
// Папка результатов
resultRoot := *resultDir
if resultRoot == "" {
resultRoot = cfg.Dir.Out
if resultRoot == "" {
resultRoot = cfg.OutDir
}
if resultRoot == "" {
resultRoot = filepath.Join(rootPath, "result")
} else {
resultRoot = filepath.Clean(resultRoot)
}
}
// --- Context с отменой по сигналу (graceful shutdown) ---
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigCh
fmt.Fprintln(os.Stderr, "\nПрерывание... завершаем работу.")
cancel()
}()
// --- Composition Root: сборка зависимостей (DI без фреймворка) ---
lister := infrastructure.NewFSFolderLister()
extractor := infrastructure.NewTagMetadataExtractor()
searcher := infrastructure.NewTorrAPIClient(effectiveAPIURL)
writer := infrastructure.NewFSResultWriter()
downloader := infrastructure.NewHTTPCoverDownloader()
presenter := presentation.NewConsolePresenter()
// Инициализация OpenRouter клиента (если указан API ключ)
var llmClient domain.LLMClient
if cfg.OpenRouter.APIKey != "" {
orClient := infrastructure.NewOpenRouterClient(infrastructure.OpenRouterConfig{
APIKey: cfg.OpenRouter.APIKey,
BaseURL: cfg.OpenRouter.BaseURL,
Timeout: cfg.OpenRouter.Timeout,
Model: cfg.OpenRouter.Model,
Prompt: cfg.OpenRouter.Prompt,
MaxRetries: cfg.OpenRouter.MaxRetries,
RetryBackoff: cfg.OpenRouter.RetryBackoff,
RetryBackoffMax: cfg.OpenRouter.RetryBackoffMax,
})
llmClient = orClient
modelName := cfg.OpenRouter.Model
if modelName == "" {
modelName = "openai/gpt-3.5-turbo"
}
fmt.Printf("OpenRouter: включен (модель: %s)\n", modelName)
} else {
fmt.Printf("OpenRouter: выключен (не указан API ключ)\n")
}
// --- Выполнение ---
fmt.Printf("Сканирование: %s\n", rootPath)
fmt.Printf("TorrAPI: %s\n", effectiveAPIURL)
fmt.Printf("Результат: %s\n", resultRoot)
fmt.Printf("Воркеры: %d\n\n", *workers)
start := time.Now()
// Получаем список папок для прогресс-бара
folders, err := lister.ListSubfolders(ctx, rootPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Ошибка при сканировании папок: %v\n", err)
os.Exit(1)
}
if len(folders) == 0 {
fmt.Println("Аудиокниги не найдены.")
os.Exit(0)
}
fmt.Printf("Найдено аудиокниг: %d\n\n", len(folders))
// Создаём логгер с прогресс-баром
logger := presentation.NewConsoleLoggerWithProgress(len(folders))
defer logger.Finish()
processUC := usecase.NewProcessAudioBooksUseCase(
lister, extractor, searcher, writer, downloader, llmClient, logger, *workers,
)
results, err := processUC.ExecuteForFolders(ctx, folders, resultRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
os.Exit(1)
}
presenter.RenderProcessResults(results)
fmt.Printf("\nВремя выполнения: %s\n", time.Since(start).Round(time.Millisecond))
}
func loadConfig(path string) appConfig {
data, err := os.ReadFile(path)
if err != nil {
return appConfig{}
}
var cfg appConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return appConfig{}
}
return cfg
}