Files
2026-02-23 22:06:49 +03:00

314 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}