// 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 }