- Добавлен ConsoleLogger для подробного логирования этапов обработки аудиокниг в консоли. - Введен ConsolePresenter для форматированного вывода результатов сканирования в консоль. - Создан ProcessAudioBooksUseCase для обработки полного конвейера обработки аудиокниг, включая сканирование папок, извлечение метаданных, поиск торрентов и запись результатов. - Реализована проверка LLM для улучшения метаданных. - Добавлена обработка ошибок и логирование на всех этапах обработки.
214 lines
6.9 KiB
Go
214 lines
6.9 KiB
Go
// 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
|
||
}
|