// Package main — точка входа. Собирает зависимости (Composition Root) // и запускает полный конвейер обработки аудиокниг. package main import ( "context" "flag" "fmt" "os" "os/signal" "path/filepath" "strconv" "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" "github.com/joho/godotenv" ) var version = "dev" // переопределяется при сборке: -ldflags "-X main.version=X.Y.Z" type appConfig struct { Dir struct { In string Out string } TorrAPI struct { URL string } Processing struct { Workers int Timeout time.Duration SearchRetries int SearchRetryDelay time.Duration SearchConcurrency int } OpenRouter struct { APIKey string BaseURL string Timeout time.Duration Model string Prompt string MaxRetries int RetryBackoff time.Duration RetryBackoffMax time.Duration } } func main() { // Устанавливаем UTF-8 кодировку для консоли Windows (исправляет кракозябры с кириллицей) infrastructure.SetConsoleUTF8() // --- CLI-флаги (переопределяют config.yaml) --- workers := flag.Int("workers", 0, "количество параллельных воркеров (0 = из .env)") timeout := flag.Duration("timeout", 0, "таймаут обработки (0 = из .env)") apiURL := flag.String("api", "", "адрес TorrAPI сервера (переопределяет .env)") 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 из .env.") 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(".env") 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 == "" { flag.Usage() fmt.Fprintln(os.Stderr, "Ошибка: не задан входной каталог (укажите аргумент или DIR_IN в .env).") 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 = filepath.Join(rootPath, "result") } else { resultRoot = filepath.Clean(resultRoot) } } // --- Применяем приоритет: CLI > config.yaml > defaults --- effectiveWorkers := *workers if effectiveWorkers <= 0 { effectiveWorkers = cfg.Processing.Workers } if effectiveWorkers <= 0 { effectiveWorkers = 2 } effectiveTimeout := *timeout if effectiveTimeout <= 0 { effectiveTimeout = cfg.Processing.Timeout } if effectiveTimeout <= 0 { effectiveTimeout = 5 * time.Minute } // --- Context с отменой по сигналу (graceful shutdown) --- ctx, cancel := context.WithTimeout(context.Background(), effectiveTimeout) defer cancel() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) go func() { <-sigCh cancel() // TUI завершится сам когда обработка остановится }() // --- 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 llmLabel := "выключен (не указан API ключ)" 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" } llmLabel = fmt.Sprintf("включен (%s)", modelName) } start := time.Now() // Сканируем список папок до запуска TUI (нужен total для прогресс-бара). 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) } // --- TUI-логгер (Bubbletea, в стиле deploy/main.go) --- // infoStr встраивается в шапку TUI для отображения конфига. infoStr := fmt.Sprintf("%s | TorrAPI: %s | LLM: %s", rootPath, effectiveAPIURL, llmLabel) tuiLog := presentation.NewTUILogger(len(folders), infoStr, resultRoot, effectiveAPIURL, version, effectiveWorkers) procCfg := usecase.ProcessingConfig{ Workers: effectiveWorkers, SearchRetries: cfg.Processing.SearchRetries, SearchRetryDelay: cfg.Processing.SearchRetryDelay, SearchConcurrency: cfg.Processing.SearchConcurrency, } processUC := usecase.NewProcessAudioBooksUseCase( lister, extractor, searcher, writer, downloader, llmClient, tuiLog, procCfg, ) // Канал для передачи результатов из горутины обработки в main. type processOutcome struct { results []domain.ProcessResult err error } outcomeCh := make(chan processOutcome, 1) // Запускаем обработку в фоновой горутине. // Используем ExecuteForFolders напрямую — список папок уже получен выше. go func() { res, procErr := processUC.ExecuteForFolders(ctx, folders, resultRoot) tuiLog.SetDone(procErr) outcomeCh <- processOutcome{results: res, err: procErr} }() // Запускаем TUI (блокирует до завершения обработки или нажатия q/Ctrl+C). tuiLog.Run(cancel) // Ждём завершения фоновой горутины (быстро — ctx уже отменён если вышли раньше). outcome := <-outcomeCh if outcome.err != nil && ctx.Err() == nil { // Реальная ошибка (не отмена контекста) fmt.Fprintf(os.Stderr, "Ошибка: %v\n", outcome.err) os.Exit(1) } presenter.RenderProcessResults(outcome.results) fmt.Printf("\nВремя выполнения: %s\n", time.Since(start).Round(time.Millisecond)) } // defaultOpenRouterPrompt используется если OPENROUTER_PROMPT не задан в .env. const defaultOpenRouterPrompt = `Ты — эксперт по метаданным русскоязычных аудиокниг. Твоя задача — проверить и исправить метаданные. ПРАВИЛА ДЛЯ АВТОРА: 1. Автор ВСЕГДА должен быть в формате: "Фамилия Имя" (без отчества) 2. Если указано "Имя Фамилия" — переставь в правильный порядок 3. Если есть отчество (три слова) — убери его, оставь только "Фамилия Имя" 4. Если несколько авторов — обработай каждого по тем же правилам, раздели запятыми ПРАВИЛА ДЛЯ НАЗВАНИЯ КНИГИ (title): 1. Убери номера серий, книг типа: "Книга 1", "01", "#1", "Том 2" 2. Убери название серии, если оно дублируется 3. Убери служебные слова: "Аудиокнига", "MP3", "читает" 4. Убери имя автора, если оно попало в название 5. Убери год издания из названия 6. Оставь только чистое название произведения ФОРМАТ ОТВЕТА (строго JSON): { "author": "Исправленная Фамилия Имя", "title": "Исправленное название без лишнего", "author_fixed": true/false, "title_fixed": true/false, "changes": "краткое описание сделанных изменений" } Если исправления не требуются, верни исходные значения с флагами false.` func loadConfig(envFile string) appConfig { // Загружаем .env файл; переменные окружения ОС имеют приоритет (godotenv.Load не перезаписывает) if err := godotenv.Load(envFile); err != nil && !os.IsNotExist(err) { fmt.Fprintf(os.Stderr, ".env: ошибка чтения: %v\n", err) } var cfg appConfig cfg.Dir.In = os.Getenv("DIR_IN") cfg.Dir.Out = os.Getenv("DIR_OUT") cfg.TorrAPI.URL = os.Getenv("TORRAPI_URL") if v := os.Getenv("PROCESSING_WORKERS"); v != "" { cfg.Processing.Workers, _ = strconv.Atoi(v) } if v := os.Getenv("PROCESSING_TIMEOUT"); v != "" { cfg.Processing.Timeout, _ = time.ParseDuration(v) } if v := os.Getenv("PROCESSING_SEARCH_RETRIES"); v != "" { cfg.Processing.SearchRetries, _ = strconv.Atoi(v) } if v := os.Getenv("PROCESSING_SEARCH_RETRY_DELAY"); v != "" { cfg.Processing.SearchRetryDelay, _ = time.ParseDuration(v) } if v := os.Getenv("PROCESSING_SEARCH_CONCURRENCY"); v != "" { cfg.Processing.SearchConcurrency, _ = strconv.Atoi(v) } cfg.OpenRouter.APIKey = os.Getenv("OPENROUTER_API_KEY") cfg.OpenRouter.BaseURL = os.Getenv("OPENROUTER_BASE_URL") if v := os.Getenv("OPENROUTER_TIMEOUT"); v != "" { cfg.OpenRouter.Timeout, _ = time.ParseDuration(v) } cfg.OpenRouter.Model = os.Getenv("OPENROUTER_MODEL") cfg.OpenRouter.Prompt = os.Getenv("OPENROUTER_PROMPT") if cfg.OpenRouter.Prompt == "" { cfg.OpenRouter.Prompt = defaultOpenRouterPrompt } if v := os.Getenv("OPENROUTER_MAX_RETRIES"); v != "" { cfg.OpenRouter.MaxRetries, _ = strconv.Atoi(v) } if v := os.Getenv("OPENROUTER_RETRY_BACKOFF"); v != "" { cfg.OpenRouter.RetryBackoff, _ = time.ParseDuration(v) } if v := os.Getenv("OPENROUTER_RETRY_BACKOFF_MAX"); v != "" { cfg.OpenRouter.RetryBackoffMax, _ = time.ParseDuration(v) } return cfg }