314 lines
12 KiB
Go
314 lines
12 KiB
Go
// 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
|
||
}
|