# Алгоритм конвейера Обработка одной книги проходит через несколько последовательных шагов внутри функции `processOneBook`. Все книги обрабатываются **параллельно** в Worker Pool (Fan-Out / Fan-In). --- ## Высокоуровневая схема ``` Входная папка │ ▼ ┌─────────────────────────────────────────┐ │ Шаг 1: Сканирование подпапок │ │ FolderLister.ListSubfolders() │ └─────────────────────────────────────────┘ │ ▼ (список папок → channel) ┌─────────────────────────────────────────┐ │ Fan-Out: N воркеров (Worker Pool) │ │ каждый воркер → processOneBook() │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Шаг 2: Извлечение метаданных │ │ MetadataExtractor.Extract() │ │ + nameparser (из имени папки) │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Шаг 3: Поиск на трекерах │ │ (до N попыток с паузой) │ │ TorrentSearcher.Search() │ │ + TorrentSearcher.GetDetail() │ │ ├─ Найдено → Шаг 4 │ │ └─ Не найдено → moveToErrorFolder() │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Шаг 4: LLM нормализация (опционально) │ │ LLMClient.NormalizeMetadata() │ │ (исправляет автора и название) │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ Шаг 5: Запись результата │ │ ResultWriter.WriteResult() │ │ + CoverDownloader.Download() │ │ ├─ Новая папка → result/<Б>/<Авт>/ │ │ └─ Дубликат → DUPLICATE/<Б>/<Авт>/ │ └─────────────────────────────────────────┘ │ ▼ Fan-In: сбор ProcessResult ``` --- ## Шаг 1 — Сканирование папок `FSFolderLister` обходит переданный корневой каталог и возвращает список **непосредственных** подпапок. Папки `result`, `ERROR`, `DUPLICATE` пропускаются автоматически (их имена совпадают с именами системных выходных папок). --- ## Шаг 2 — Извлечение метаданных `TagMetadataExtractor` находит первый аудиофайл в папке и читает теги: - ID3v1/ID3v2 для `.mp3` - Vorbis Comment для `.ogg`, `.flac`, `.opus` - MP4 atoms для `.m4b`, `.m4a`, `.aac` **Параллельно** — `nameparser` разбирает имя папки по шаблонам: - `Фамилия И.О. - Название` - `Автор — Название` - `[Серия] Название - Автор` Приоритет: теги из файла > nameparser > пустые строки. --- ## Шаг 3 — Поиск на трекерах (с ретраями) ``` для попытки := 1..SearchRetries: результаты = TorrAPI.Search(title) если результаты пусты и title != author: результаты = TorrAPI.Search(author) если найдено: detail = TorrAPI.GetDetail(лучший_результат) выйти из цикла иначе: подождать SearchRetryDelay (по умолчанию 3s) если после всех попыток ничего не найдено: записать _error.txt с описанием переместить папку в result/ERROR/<имя_папки>/ вернуть ProcessResult{Status: "error"} ``` **Приоритет трекеров**: Rutracker → Rutor → Kinozal → остальные. Параллелизм запросов к TorrAPI ограничен семафором `searchSem` (buffer = `search_concurrency`). --- ## Шаг 4 — LLM нормализация (опционально) Если задан `openrouter.api_key`, отправляется промпт: ``` Входные данные: {raw_author}, {raw_title} Ожидаемый ответ: {"author": "Фамилия Имя", "title": "Название"} ``` LLM-ответ применяется только если оба поля непусты. При ошибке API книга обрабатывается с исходными тегами (не блокирует конвейер). --- ## Шаг 5 — Запись результата `FSResultWriter` создаёт структуру: ``` result/ <первая_буква_автора>/ <Автор>/ <Автор> — <Название> [<Год>]/ metadata.json cover.jpg (если найдена обложка) <аудиофайлы...> ``` ### Проверка на DUPLICATE Перед созданием папки проверяется, не существует ли уже `destDir`. Если существует — книга идёт в: ``` result/DUPLICATE/<первая_буква>/<Автор>/<Название>/ result/DUPLICATE/<первая_буква>/<Автор>/<Название>_2/ # если уже есть result/DUPLICATE/<первая_буква>/<Автор>/<Название>_3/ # и т.д. ``` --- ## Обработка ошибок | Ситуация | Действие | |---|---| | Папка не найдена на трекерах после N попыток | `result/ERROR/<имя>/` + `_error.txt` | | Папка с таким именем уже существует в результатах | `result/DUPLICATE/.../` с суффиксом `_2`, `_3`... | | Ошибка LLM API | Лог WARN, обработка продолжается с исходными тегами | | Ошибка скачивания обложки | Лог WARN, книга сохраняется без `cover.jpg` | | Отмена контекста (Ctrl+C / таймаут) | Текущий воркер завершается, остальные — graceful stop | --- ## Параллелизм ``` main goroutine processing goroutine │ │ │ go ExecuteForFolders() │ │──────────────────────────►│ │ │ Fan-Out: N воркеров │ tuiLog.Run(cancel) │──(jobs channel)──►│worker1│ │ (блокирует main) │ │worker2│ │ │ │worker3│ │ │ Fan-In: results channel │ │◄───────────────────────── │ outcomeCh ◄──────────│ │◄────────────────────────── │ presenter.RenderResults() ```