Добавлены страницы вики для GenAudioBookInfo: Home, Installation, Makefile, OpenRouter, Output Structure, TorrAPI и Sidebar.

Создана структура документации, описывающая функциональность, установку, использование CLI, архитектуру и интеграции с TorrAPI и OpenRouter.
Добавлены примеры конфигурации и метаданных, а также описание структуры выходных данных.
This commit is contained in:
Dmitriy Fofanov
2026-02-23 13:19:39 +03:00
parent 107f57d2cc
commit 41fb62f62e
31 changed files with 4181 additions and 1376 deletions
+45
View File
@@ -0,0 +1,45 @@
# =============================================================================
# GenAudioBookInfo — пример конфигурации
# Скопируй этот файл в .env и заполни своими значениями:
# cp .env.example .env
# =============================================================================
# --- Пути ---
# DIR_IN — входная папка с аудиокнигами (или передать как аргумент CLI)
# DIR_OUT — папка результатов (по умолчанию: <DIR_IN>/result)
DIR_IN=D:\Audiobooks\Incoming
DIR_OUT=D:\Audiobooks\Result
# --- TorrAPI ---
# Адрес TorrServer (https://github.com/YouROK/TorrServer)
# Переопределяется флагом -api
TORRAPI_URL=http://localhost:9200
# --- Параметры обработки ---
# PROCESSING_WORKERS — параллельных воркеров (переопределяется флагом -workers)
# PROCESSING_TIMEOUT — общий таймаут сессии (переопределяется флагом -timeout)
# PROCESSING_SEARCH_RETRIES — попыток поиска книги на трекерах
# PROCESSING_SEARCH_RETRY_DELAY — пауза между попытками поиска
# PROCESSING_SEARCH_CONCURRENCY — макс. параллельных запросов к TorrAPI
PROCESSING_WORKERS=2
PROCESSING_TIMEOUT=5m
PROCESSING_SEARCH_RETRIES=3
PROCESSING_SEARCH_RETRY_DELAY=3s
PROCESSING_SEARCH_CONCURRENCY=2
# --- OpenRouter LLM (опционально) ---
# Если OPENROUTER_API_KEY пуст — LLM выключен, используются только теги и трекеры.
# Ключ получить на: https://openrouter.ai/keys
# Список моделей: https://openrouter.ai/models
OPENROUTER_API_KEY=sk-or-v1-your-api-key-here
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_TIMEOUT=120s
OPENROUTER_MODEL=openai/gpt-4o-mini
OPENROUTER_MAX_RETRIES=3
OPENROUTER_RETRY_BACKOFF=1s
OPENROUTER_RETRY_BACKOFF_MAX=8s
# OPENROUTER_PROMPT=
# Опционально. Если не задан — используется встроенный промпт.
# Промпт должен требовать ответ строго в JSON:
# {"author": "Фамилия Имя", "title": "Название", ...}
+362
View File
@@ -0,0 +1,362 @@
# ══════════════════════════════════════════════════════════════════════════════
# CI/CD Pipeline: Сборка и публикация релиза GenAudioBookInfo
# ══════════════════════════════════════════════════════════════════════════════
#
# ЗАПУСК ТОЛЬКО ЧЕРЕЗ make — два способа:
#
# 1. make release-tag VERSION=2.1.0
# Создаёт аннотированный git-тег v2.1.0 и пушит его в origin.
# Workflow запускается автоматически по событию push.tags.
#
# 2. make ci-release VERSION=2.1.0 GITEA_TOKEN=<токен>
# Вызывает Gitea API (workflow_dispatch) — запускает вручную
# без создания тега. Удобно для тестирования пайплайна.
#
# ──────────────────────────────────────────────────────────────────────────────
# ЭТАПЫ ПАЙПЛАЙНА:
#
# [Этап 1] quality — Статический анализ (go vet) + Unit-тесты
# Блокирует следующие этапы при ошибке.
#
# [Этап 2] build — Определение версии, кросс-компиляция 15 бинарников
# для Linux / macOS / Windows / FreeBSD / OpenBSD /
# NetBSD / MIPS / RISC-V, упаковка в архивы,
# генерация SHA256 контрольных сумм.
#
# [Этап 3] publish — Создание Gitea Release через API,
# загрузка всех архивов и checksums-sha256.txt.
#
# ──────────────────────────────────────────────────────────────────────────────
# НЕОБХОДИМАЯ НАСТРОЙКА:
#
# В настройках репозитория Gitea → Settings → Secrets добавить:
# GITEA_TOKEN — токен с правами write:repository и write:release
# (обычно автоматически инжектируется runner'ом)
#
# ══════════════════════════════════════════════════════════════════════════════
name: "Release CI/CD"
on:
# ── Способ 1: запуск через make ci-release ────────────────────────────────
workflow_dispatch:
inputs:
version:
description: "Версия релиза (например: 2.1.0). Без префикса 'v'."
required: false
default: ""
prerelease:
description: "Отметить как пре-релиз (alpha/beta)?"
required: false
default: "false"
# ── Способ 2: запуск через make release-tag ───────────────────────────────
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]*"
# ──────────────────────────────────────────────────────────────────────────────
# Глобальные переменные окружения (доступны во всех jobs)
# ──────────────────────────────────────────────────────────────────────────────
env:
GO_VERSION: "1.24" # Версия Go для сборки
APP_NAME: genaudiobookinfo
BUILD_DIR: build
# ══════════════════════════════════════════════════════════════════════════════
jobs:
# ────────────────────────────────────────────────────────────────────────────
# ЭТАП 1: Проверка качества кода
# ────────────────────────────────────────────────────────────────────────────
quality:
name: "Этап 1 — Качество кода"
runs-on: ubuntu-latest
steps:
- name: "Получение исходного кода"
uses: actions/checkout@v4
- name: "Установка Go ${{ env.GO_VERSION }}"
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
- name: "Загрузка зависимостей (go mod download)"
run: make deps
- name: "Статический анализ (go vet)"
# Проверяет корректность кода без запуска: подозрительные
# конструкции, неправильные форматные строки, ошибки типов.
run: make vet
- name: "Unit-тесты (go test)"
# -count=1 — отключает кеш результатов (всегда свежий прогон)
# -timeout 5m — предел времени выполнения тестов
run: go test ./... -count=1 -timeout 5m
# ────────────────────────────────────────────────────────────────────────────
# ЭТАП 2: Кросс-компиляция и подготовка артефактов
# ────────────────────────────────────────────────────────────────────────────
build:
name: "Этап 2 — Кросс-компиляция"
runs-on: ubuntu-latest
needs: quality # Запускается только после успешного прохождения Этапа 1
outputs:
# Версия и тег передаются в Этап 3 через outputs
version: ${{ steps.ver.outputs.version }}
tag: ${{ steps.ver.outputs.tag }}
steps:
- name: "Получение исходного кода (полная история для тегов)"
uses: actions/checkout@v4
with:
fetch-depth: 0 # Нужна полная история для git describe
- name: "Установка Go ${{ env.GO_VERSION }}"
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache: true
# ── Определение версии ────────────────────────────────────────────────
# Приоритет: git-тег > ручной ввод (inputs.version) > значение в Makefile
- name: "Определение версии релиза"
id: ver
run: |
if [ "${{ github.event_name }}" = "push" ]; then
# Запуск через make release-tag: версия берётся из имени тега
TAG="${{ github.ref_name }}" # например: v2.0.0
VER="${TAG#v}" # например: 2.0.0
elif [ -n "${{ inputs.version }}" ]; then
# Запуск через make ci-release с явной версией
VER="${{ inputs.version }}"
TAG="v${VER}"
else
# Версия не задана — берём из Makefile (строка VERSION := X.Y.Z)
VER=$(grep '^VERSION' Makefile | head -1 | sed 's/.*:=\s*//' | tr -d ' ')
TAG="v${VER}"
fi
echo "version=${VER}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "──────────────────────────────────"
echo " Версия: ${VER}"
echo " Git-тег: ${TAG}"
echo " Коммит: ${{ github.sha }}"
echo "──────────────────────────────────"
- name: "Загрузка зависимостей"
run: make deps
# ── Кросс-компиляция ─────────────────────────────────────────────────
# make build-all собирает 15 бинарников через GOOS/GOARCH
# VERSION перезаписывает значение из Makefile (инжектируется в ldflags)
- name: "Кросс-компиляция для всех платформ"
run: |
make build-all VERSION=${{ steps.ver.outputs.version }}
echo ""
echo "Собранные бинарники:"
ls -lh ${{ env.BUILD_DIR }}/
# ── Упаковка в архивы ─────────────────────────────────────────────────
# Windows → ZIP (стандарт для Windows-пользователей)
# Всё остальное → tar.gz (стандарт для Unix-систем)
- name: "Создание архивов (zip / tar.gz)"
run: |
cd ${{ env.BUILD_DIR }}
mkdir -p archives
echo "=== Windows → ZIP ==="
for f in *-windows-*.exe; do
[ -f "$f" ] || continue
ARCNAME="${f%.exe}.zip"
echo " ${f} → archives/${ARCNAME}"
zip "archives/${ARCNAME}" "$f"
done
echo "=== Unix → TAR.GZ ==="
for f in \
*-linux-* \
*-darwin-* \
*-freebsd-* \
*-openbsd-* \
*-netbsd-*; do
[[ "$f" == *.exe ]] && continue
[ -f "$f" ] || continue
ARCNAME="${f}.tar.gz"
echo " ${f} → archives/${ARCNAME}"
tar -czf "archives/${ARCNAME}" "$f"
done
echo ""
echo "Готовые архивы:"
ls -lh archives/
# ── Контрольные суммы ─────────────────────────────────────────────────
# SHA256 для каждого архива — пользователи могут проверить целостность
- name: "Генерация контрольных сумм SHA256"
run: |
cd ${{ env.BUILD_DIR }}/archives
sha256sum * | tee checksums-sha256.txt
echo ""
echo "checksums-sha256.txt создан ($(wc -l < checksums-sha256.txt) файлов)"
# ── Передача артефактов в Этап 3 ─────────────────────────────────────
- name: "Сохранение артефактов"
uses: actions/upload-artifact@v4
with:
name: release-assets-${{ steps.ver.outputs.tag }}
path: ${{ env.BUILD_DIR }}/archives/
retention-days: 5
if-no-files-found: error # Ошибка если архивы не созданы
# ────────────────────────────────────────────────────────────────────────────
# ЭТАП 3: Публикация релиза в Gitea
# ────────────────────────────────────────────────────────────────────────────
publish:
name: "Этап 3 — Публикация релиза"
runs-on: ubuntu-latest
needs: build # Запускается только после успешного Этапа 2
env:
VERSION: ${{ needs.build.outputs.version }}
TAG: ${{ needs.build.outputs.tag }}
PRERELEASE: ${{ inputs.prerelease || 'false' }}
steps:
- name: "Скачивание артефактов из Этапа 2"
uses: actions/download-artifact@v4
with:
name: release-assets-${{ needs.build.outputs.tag }}
path: ./artifacts
- name: "Список артефактов для публикации"
run: |
echo "Артефакты ($(ls ./artifacts | wc -l) файлов):"
ls -lh ./artifacts/
# ── Создание Gitea Release и загрузка файлов ─────────────────────────
# Используется Gitea REST API v1:
# POST /api/v1/repos/{owner}/{repo}/releases — создать релиз
# POST /api/v1/repos/{owner}/{repo}/releases/{id}/assets — загрузить файл
- name: "Создание релиза в Gitea и загрузка файлов"
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
GITEA_URL="${{ github.server_url }}"
REPO="${{ github.repository }}"
COMMIT="${{ github.sha }}"
DATE=$(date -u '+%Y-%m-%d %H:%M UTC')
echo "──────────────────────────────────────────────"
echo " Сервер: ${GITEA_URL}"
echo " Репо: ${REPO}"
echo " Версия: ${TAG}"
echo " Коммит: ${COMMIT}"
echo "──────────────────────────────────────────────"
# ── Тело описания релиза (Markdown) ────────────────────────────
RELEASE_BODY="## GenAudioBookInfo ${TAG}
### Платформы
| ОС | Архитектура | Файл |
|---|---|---|
| Windows | amd64 (64-бит) | \`genaudiobookinfo-windows-amd64.zip\` |
| Windows | 386 (32-бит) | \`genaudiobookinfo-windows-386.zip\` |
| Windows | arm64 | \`genaudiobookinfo-windows-arm64.zip\` |
| Linux | amd64 | \`genaudiobookinfo-linux-amd64.tar.gz\` |
| Linux | arm64 | \`genaudiobookinfo-linux-arm64.tar.gz\` |
| Linux | armv7 | \`genaudiobookinfo-linux-armv7.tar.gz\` |
| Linux | 386 | \`genaudiobookinfo-linux-386.tar.gz\` |
| Linux | MIPS (big-endian) | \`genaudiobookinfo-linux-mips.tar.gz\` |
| Linux | MIPS (little-endian) | \`genaudiobookinfo-linux-mipsle.tar.gz\` |
| Linux | RISC-V 64 | \`genaudiobookinfo-linux-riscv64.tar.gz\` |
| macOS | amd64 (Intel) | \`genaudiobookinfo-darwin-amd64.tar.gz\` |
| macOS | arm64 (Apple Silicon) | \`genaudiobookinfo-darwin-arm64.tar.gz\` |
| FreeBSD | amd64 | \`genaudiobookinfo-freebsd-amd64.tar.gz\` |
| FreeBSD | arm64 | \`genaudiobookinfo-freebsd-arm64.tar.gz\` |
| OpenBSD | amd64 | \`genaudiobookinfo-openbsd-amd64.tar.gz\` |
| NetBSD | amd64 | \`genaudiobookinfo-netbsd-amd64.tar.gz\` |
> Контрольные суммы файлов: \`checksums-sha256.txt\`
### Сборка
- **Коммит:** \`${COMMIT}\`
- **Дата:** ${DATE}
- **Go:** ${{ env.GO_VERSION }}"
# ── Создать релиз через API ─────────────────────────────────────
echo ""
echo "Создаём релиз ${TAG}..."
RELEASE_JSON=$(curl -sf -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
-d "{
\"tag_name\": \"${TAG}\",
\"target_commitish\": \"${COMMIT}\",
\"name\": \"${{ env.APP_NAME }} ${TAG}\",
\"body\": $(echo "$RELEASE_BODY" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'),
\"draft\": false,
\"prerelease\": ${PRERELEASE}
}")
RELEASE_ID=$(echo "$RELEASE_JSON" | \
python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true)
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "None" ]; then
echo "ОШИБКА: не удалось создать релиз!"
echo "Ответ API:"
echo "$RELEASE_JSON" | python3 -m json.tool 2>/dev/null || echo "$RELEASE_JSON"
exit 1
fi
echo "Релиз создан: ID=${RELEASE_ID}"
# ── Загрузить каждый артефакт ────────────────────────────────────
echo ""
echo "Загружаем артефакты..."
UPLOAD_OK=0
UPLOAD_FAIL=0
for FILE in ./artifacts/*; do
[ -f "$FILE" ] || continue
FILENAME=$(basename "$FILE")
FILESIZE=$(du -sh "$FILE" | cut -f1)
printf " %-55s [%5s] " "${FILENAME}" "${FILESIZE}"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${FILE}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${FILENAME}")
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo "OK (HTTP ${HTTP_CODE})"
UPLOAD_OK=$((UPLOAD_OK + 1))
else
echo "ОШИБКА (HTTP ${HTTP_CODE})"
UPLOAD_FAIL=$((UPLOAD_FAIL + 1))
fi
done
# ── Итог ─────────────────────────────────────────────────────────
echo ""
echo "══════════════════════════════════════════════"
echo " Загружено успешно: ${UPLOAD_OK}"
echo " Ошибок: ${UPLOAD_FAIL}"
echo " Релиз: ${GITEA_URL}/${REPO}/releases/tag/${TAG}"
echo "══════════════════════════════════════════════"
if [ "$UPLOAD_FAIL" -gt 0 ]; then
echo "Часть файлов не удалось загрузить!"
exit 1
fi
+3
View File
@@ -8,3 +8,6 @@
vendor/
.idea/
.vscode/
# Конфигурация с секретами — не коммитить
.env
+403 -108
View File
@@ -1,65 +1,137 @@
# GenAudioBookInfo Makefile
# Сборка, запуск, тестирование под все платформы
# Работает под Windows (PowerShell), Linux и macOS
APP_NAME := genaudiobookinfo
VERSION := 1.0.0
VERSION := 2.0.0
BUILD_DIR := build
CMD_PATH := ./cmd/genaudiobookinfo
GO := go
GOFLAGS := -trimpath
LDFLAGS := -s -w -X main.version=$(VERSION)
# Директории проекта
INPUT_DIR := ./audiobook
# Директория результатов (для clean-results)
OUTPUT_DIR := ./result
# Дата и коммит для информации о сборке
BUILD_DATE := $(shell powershell -Command "Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ'" 2>nul || echo unknown)
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>nul || echo unknown)
# ── Параметры Gitea для CI/CD-команд ─────────────────────────────────────────
# Переопределить через env или CLI: make release-tag GITEA_URL=https://...
GITEA_URL ?= https://github.dfv24.com
GITEA_REPO ?= fofanov/genaudiobookinfo
GITEA_TOKEN ?=
# ============================================================================
# Определяем оболочку по платформе
# ============================================================================
ifeq ($(OS),Windows_NT)
SHELL = powershell.exe
.SHELLFLAGS = -NoProfile -ExecutionPolicy Bypass -Command
BUILD_DATE := $(shell Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')
GIT_COMMIT := $(shell try { git rev-parse --short HEAD 2>$$null } catch { 'unknown' })
else
SHELL := /bin/sh
BUILD_DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo unknown)
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
endif
# ============================================================================
# Основные команды
# ============================================================================
.PHONY: all build run clean test lint deps tidy fmt vet help clean-results
.PHONY: all build run clean test lint deps tidy fmt vet help clean-results ensure-build-dir
.PHONY: release-tag release-tag-delete ci-release ci-check release-local
## Сборка + тесты (цель по умолчанию при явном вызове make all)
all: build vet
# ============================================================================
# Вспомогательная цель: создать build/ если не существует
# ============================================================================
ifeq ($(OS),Windows_NT)
ensure-build-dir:
New-Item -ItemType Directory -Force '$(BUILD_DIR)' | Out-Null
else
ensure-build-dir:
@mkdir -p $(BUILD_DIR)
endif
## Сборка для текущей платформы
build:
@echo ">>> Сборка $(APP_NAME) для текущей платформы..."
ifeq ($(OS),Windows_NT)
build: ensure-build-dir
$(info >>> Сборка $(APP_NAME) для текущей платформы...)
$$env:GOOS='windows'; $$env:GOARCH='amd64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME).exe $(CMD_PATH)
$(info >>> Готово: $(BUILD_DIR)/$(APP_NAME).exe)
else
build: ensure-build-dir
$(info >>> Сборка $(APP_NAME) для текущей платформы...)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(CMD_PATH)
@echo ">>> Готово: $(BUILD_DIR)/$(APP_NAME)"
$(info >>> Готово: $(BUILD_DIR)/$(APP_NAME))
endif
## Сборка для Windows
ifeq ($(OS),Windows_NT)
build-windows-current:
@echo ">>> Сборка $(APP_NAME).exe для Windows..."
$(info >>> Сборка $(APP_NAME).exe для Windows...)
$$env:GOOS='windows'; $$env:GOARCH='amd64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(APP_NAME).exe $(CMD_PATH)
$(info >>> Готово: $(APP_NAME).exe)
else
build-windows-current:
$(info >>> Сборка $(APP_NAME).exe для Windows...)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(APP_NAME).exe $(CMD_PATH)
@echo ">>> Готово: $(APP_NAME).exe"
$(info >>> Готово: $(APP_NAME).exe)
endif
## Запуск без сборки (с параметрами по умолчанию)
ifeq ($(OS),Windows_NT)
run:
@echo ">>> Запуск $(APP_NAME)..."
$(GO) run $(CMD_PATH) -workers 8 -timeout 60
$(info >>> Запуск $(APP_NAME)...)
$(GO) run $(CMD_PATH) -workers 8 -timeout 60m
else
run:
$(info >>> Запуск $(APP_NAME)...)
$(GO) run $(CMD_PATH) -workers 8 -timeout 60m
endif
## Запуск с собственными параметрами (пример: make run-custom ARGS="-workers 4 -timeout 30")
ifeq ($(OS),Windows_NT)
run-custom:
@echo ">>> Запуск с параметрами: $(ARGS)..."
$(info >>> Запуск с параметрами: $(ARGS)...)
$$env:GOOS=''; $$env:GOARCH=''; $(GO) run $(CMD_PATH) $(ARGS)
else
run-custom:
$(info >>> Запуск с параметрами: $(ARGS)...)
$(GO) run $(CMD_PATH) $(ARGS)
endif
## Запуск unit-тестов
ifeq ($(OS),Windows_NT)
test:
@echo ">>> Запуск тестов..."
$(info >>> Запуск тестов...)
$$env:GOOS=''; $$env:GOARCH=''; $(GO) test ./... -v -race -count=1
else
test:
$(info >>> Запуск тестов...)
$(GO) test ./... -v -race -count=1
endif
## Запуск тестов с покрытием
ifeq ($(OS),Windows_NT)
test-cover:
@echo ">>> Запуск тестов с покрытием..."
$(info >>> Запуск тестов с покрытием...)
$$env:GOOS=''; $$env:GOARCH=''; $(GO) test ./... -v -race -coverprofile=coverage.out
$(GO) tool cover -html=coverage.out -o coverage.html
$(info >>> Отчёт: coverage.html)
else
test-cover:
$(info >>> Запуск тестов с покрытием...)
$(GO) test ./... -v -race -coverprofile=coverage.out
$(GO) tool cover -html=coverage.out -o coverage.html
@echo ">>> Отчёт: coverage.html"
$(info >>> Отчёт: coverage.html)
endif
## Установка зависимостей
deps:
@echo ">>> Установка зависимостей..."
$(info >>> Установка зависимостей...)
$(GO) mod download
## Очистка и обновление go.mod
@@ -68,34 +140,53 @@ tidy:
## Форматирование кода
fmt:
@echo ">>> Форматирование кода..."
$(info >>> Форматирование кода...)
$(GO) fmt ./...
## Статический анализ
vet:
@echo ">>> Статический анализ..."
$(info >>> Статический анализ...)
$(GO) vet ./...
## Линтер (требуется golangci-lint)
ifeq ($(OS),Windows_NT)
lint:
@echo ">>> Линтинг..."
@where golangci-lint >nul 2>&1 && golangci-lint run ./... || echo "golangci-lint не установлен: go install github.com/golangci-lint/golangci-lint/cmd/golangci-lint@latest"
$(info >>> Линтинг...)
@if (Get-Command golangci-lint -ErrorAction SilentlyContinue) { golangci-lint run ./... } else { $(info golangci-lint не установлен: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest) }
else
lint:
$(info >>> Линтинг...)
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run ./... || echo "golangci-lint не установлен: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"
endif
## Очистка артефактов сборки
ifeq ($(OS),Windows_NT)
clean:
@echo ">>> Очистка артефактов сборки..."
@if exist $(BUILD_DIR) rmdir /s /q $(BUILD_DIR)
@if exist $(APP_NAME).exe del /q $(APP_NAME).exe
@if exist coverage.out del /q coverage.out
@if exist coverage.html del /q coverage.html
@echo ">>> Готово"
$(info >>> Очистка артефактов сборки...)
-Remove-Item -Recurse -Force '$(BUILD_DIR)' -ErrorAction SilentlyContinue
-Remove-Item -Force '$(APP_NAME).exe' -ErrorAction SilentlyContinue
-Remove-Item -Force 'coverage.out','coverage.html' -ErrorAction SilentlyContinue
$(info >>> Готово)
else
clean:
$(info >>> Очистка артефактов сборки...)
@rm -rf $(BUILD_DIR) coverage.out coverage.html $(APP_NAME)
$(info >>> Готово)
endif
## Очистка результатов работы программы
ifeq ($(OS),Windows_NT)
clean-results:
@echo ">>> Очистка директории результатов..."
@if exist $(OUTPUT_DIR) rmdir /s /q $(OUTPUT_DIR)
@if not exist $(OUTPUT_DIR) mkdir $(OUTPUT_DIR)
@echo ">>> Готово"
$(info >>> Очистка директории результатов...)
-Remove-Item -Recurse -Force '$(OUTPUT_DIR)' -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force '$(OUTPUT_DIR)' | Out-Null
$(info >>> Готово)
else
clean-results:
$(info >>> Очистка директории результатов...)
@rm -rf $(OUTPUT_DIR) && mkdir -p $(OUTPUT_DIR)
$(info >>> Готово)
endif
## Полная очистка (артефакты + результаты)
clean-all: clean clean-results
@@ -108,123 +199,319 @@ clean-all: clean clean-results
.PHONY: build-all build-linux build-darwin build-windows build-arm build-freebsd build-openbsd build-netbsd
## Сборка под ВСЕ платформы
build-all: clean build-linux build-darwin build-windows build-arm build-freebsd build-openbsd build-netbsd
@echo ""
@echo "========================================="
@echo " Сборка завершена для всех платформ"
@echo "========================================="
@if exist $(BUILD_DIR) dir $(BUILD_DIR) /b
@echo ""
build-all: clean ensure-build-dir build-linux build-darwin build-windows build-arm build-freebsd build-openbsd build-netbsd
$(info )
$(info =========================================)
$(info Сборка завершена для всех платформ)
$(info =========================================)
# --- Linux ---
$(info )
# ============================================================================
# Linux
# ============================================================================
build-linux: build-linux-amd64 build-linux-386 build-linux-arm64 build-linux-arm7
ifeq ($(OS),Windows_NT)
build-linux-amd64:
@echo ">>> Linux amd64..."
$(info >>> Linux amd64...)
$$env:GOOS='linux'; $$env:GOARCH='amd64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(CMD_PATH)
else
build-linux-amd64:
$(info >>> Linux amd64...)
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(CMD_PATH)
endif
ifeq ($(OS),Windows_NT)
build-linux-386:
@echo ">>> Linux 386..."
$(info >>> Linux 386...)
$$env:GOOS='linux'; $$env:GOARCH='386'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-linux-386 $(CMD_PATH)
else
build-linux-386:
$(info >>> Linux 386...)
GOOS=linux GOARCH=386 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-386 $(CMD_PATH)
endif
ifeq ($(OS),Windows_NT)
build-linux-arm64:
@echo ">>> Linux arm64..."
$(info >>> Linux arm64...)
$$env:GOOS='linux'; $$env:GOARCH='arm64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-linux-arm64 $(CMD_PATH)
else
build-linux-arm64:
$(info >>> Linux arm64...)
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-arm64 $(CMD_PATH)
endif
ifeq ($(OS),Windows_NT)
build-linux-arm7:
@echo ">>> Linux armv7..."
$(info >>> Linux armv7...)
$$env:GOOS='linux'; $$env:GOARCH='arm'; $$env:GOARM='7'; Remove-Item Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-linux-armv7 $(CMD_PATH)
else
build-linux-arm7:
$(info >>> Linux armv7...)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-armv7 $(CMD_PATH)
endif
# --- macOS ---
# ============================================================================
# macOS
# ============================================================================
build-darwin: build-darwin-amd64 build-darwin-arm64
ifeq ($(OS),Windows_NT)
build-darwin-amd64:
@echo ">>> macOS amd64 (Intel)..."
$(info >>> macOS amd64 (Intel)...)
$$env:GOOS='darwin'; $$env:GOARCH='amd64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-darwin-amd64 $(CMD_PATH)
else
build-darwin-amd64:
$(info >>> macOS amd64 (Intel)...)
GOOS=darwin GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-amd64 $(CMD_PATH)
endif
ifeq ($(OS),Windows_NT)
build-darwin-arm64:
@echo ">>> macOS arm64 (Apple Silicon)..."
$(info >>> macOS arm64 (Apple Silicon)...)
$$env:GOOS='darwin'; $$env:GOARCH='arm64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(CMD_PATH)
else
build-darwin-arm64:
$(info >>> macOS arm64 (Apple Silicon)...)
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(CMD_PATH)
endif
# --- Windows ---
# ============================================================================
# Windows
# ============================================================================
build-windows: build-windows-amd64 build-windows-386 build-windows-arm64
ifeq ($(OS),Windows_NT)
build-windows-amd64:
@echo ">>> Windows amd64..."
$(info >>> Windows amd64...)
$$env:GOOS='windows'; $$env:GOARCH='amd64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-windows-amd64.exe $(CMD_PATH)
else
build-windows-amd64:
$(info >>> Windows amd64...)
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-windows-amd64.exe $(CMD_PATH)
endif
ifeq ($(OS),Windows_NT)
build-windows-386:
@echo ">>> Windows 386..."
$(info >>> Windows 386...)
$$env:GOOS='windows'; $$env:GOARCH='386'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-windows-386.exe $(CMD_PATH)
else
build-windows-386:
$(info >>> Windows 386...)
GOOS=windows GOARCH=386 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-windows-386.exe $(CMD_PATH)
endif
ifeq ($(OS),Windows_NT)
build-windows-arm64:
@echo ">>> Windows arm64..."
$(info >>> Windows arm64...)
$$env:GOOS='windows'; $$env:GOARCH='arm64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-windows-arm64.exe $(CMD_PATH)
else
build-windows-arm64:
$(info >>> Windows arm64...)
GOOS=windows GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-windows-arm64.exe $(CMD_PATH)
endif
# --- ARM (embedded / Raspberry Pi / роутеры) ---
# ============================================================================
# ARM / Embedded
# ============================================================================
build-arm: build-arm-mips build-arm-mipsle build-arm-riscv64
ifeq ($(OS),Windows_NT)
build-arm-mips:
@echo ">>> MIPS (big-endian)..."
$(info >>> MIPS (big-endian)...)
$$env:GOOS='linux'; $$env:GOARCH='mips'; $$env:GOMIPS='softfloat'; Remove-Item Env:GOARM -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-linux-mips $(CMD_PATH)
else
build-arm-mips:
$(info >>> MIPS (big-endian)...)
GOOS=linux GOARCH=mips GOMIPS=softfloat $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-mips $(CMD_PATH)
endif
ifeq ($(OS),Windows_NT)
build-arm-mipsle:
@echo ">>> MIPS (little-endian)..."
$(info >>> MIPS (little-endian)...)
$$env:GOOS='linux'; $$env:GOARCH='mipsle'; $$env:GOMIPS='softfloat'; Remove-Item Env:GOARM -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-linux-mipsle $(CMD_PATH)
else
build-arm-mipsle:
$(info >>> MIPS (little-endian)...)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-mipsle $(CMD_PATH)
endif
ifeq ($(OS),Windows_NT)
build-arm-riscv64:
@echo ">>> RISC-V 64..."
$(info >>> RISC-V 64...)
$$env:GOOS='linux'; $$env:GOARCH='riscv64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-linux-riscv64 $(CMD_PATH)
else
build-arm-riscv64:
$(info >>> RISC-V 64...)
GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-riscv64 $(CMD_PATH)
endif
# --- FreeBSD ---
# ============================================================================
# FreeBSD
# ============================================================================
build-freebsd: build-freebsd-amd64 build-freebsd-arm64
ifeq ($(OS),Windows_NT)
build-freebsd-amd64:
@echo ">>> FreeBSD amd64..."
$(info >>> FreeBSD amd64...)
$$env:GOOS='freebsd'; $$env:GOARCH='amd64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-freebsd-amd64 $(CMD_PATH)
else
build-freebsd-amd64:
$(info >>> FreeBSD amd64...)
GOOS=freebsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-freebsd-amd64 $(CMD_PATH)
endif
ifeq ($(OS),Windows_NT)
build-freebsd-arm64:
@echo ">>> FreeBSD arm64..."
$(info >>> FreeBSD arm64...)
$$env:GOOS='freebsd'; $$env:GOARCH='arm64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-freebsd-arm64 $(CMD_PATH)
else
build-freebsd-arm64:
$(info >>> FreeBSD arm64...)
GOOS=freebsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-freebsd-arm64 $(CMD_PATH)
endif
# --- OpenBSD ---
# ============================================================================
# OpenBSD
# ============================================================================
build-openbsd: build-openbsd-amd64
ifeq ($(OS),Windows_NT)
build-openbsd-amd64:
@echo ">>> OpenBSD amd64..."
$(info >>> OpenBSD amd64...)
$$env:GOOS='openbsd'; $$env:GOARCH='amd64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-openbsd-amd64 $(CMD_PATH)
else
build-openbsd-amd64:
$(info >>> OpenBSD amd64...)
GOOS=openbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-openbsd-amd64 $(CMD_PATH)
endif
# --- NetBSD ---
# ============================================================================
# NetBSD
# ============================================================================
build-netbsd: build-netbsd-amd64
ifeq ($(OS),Windows_NT)
build-netbsd-amd64:
@echo ">>> NetBSD amd64..."
$(info >>> NetBSD amd64...)
$$env:GOOS='netbsd'; $$env:GOARCH='amd64'; Remove-Item Env:GOARM,Env:GOMIPS -ErrorAction SilentlyContinue; $(GO) build $(GOFLAGS) -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(APP_NAME)-netbsd-amd64 $(CMD_PATH)
else
build-netbsd-amd64:
$(info >>> NetBSD amd64...)
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-netbsd-amd64 $(CMD_PATH)
endif
# ============================================================================
# Релиз (архивирование бинарников)
# Релиз
# ============================================================================
.PHONY: release checksum
## Создание архивов для всех платформ
## Копирование бинарников в build/release/ и создание архивов
ifeq ($(OS),Windows_NT)
release: build-all
@echo ">>> Создание архивов релиза..."
@if not exist $(BUILD_DIR)\release mkdir $(BUILD_DIR)\release
@echo ">>> Архивы: $(BUILD_DIR)/release/"
$(info >>> Создание релиза...)
New-Item -ItemType Directory -Force '$(BUILD_DIR)\release' | Out-Null
Get-ChildItem '$(BUILD_DIR)' -File | Where-Object { $$_.Name -ne 'release' } | Copy-Item -Destination '$(BUILD_DIR)\release\'
$(info >>> Готово: $(BUILD_DIR)/release/)
else
release: build-all
$(info >>> Создание релиза...)
@mkdir -p $(BUILD_DIR)/release
@find $(BUILD_DIR) -maxdepth 1 -type f -exec cp {} $(BUILD_DIR)/release/ \;
$(info >>> Готово: $(BUILD_DIR)/release/)
endif
## Генерация контрольных сумм SHA256
ifeq ($(OS),Windows_NT)
checksum: release
@echo ">>> Генерация SHA256..."
@cd $(BUILD_DIR)\release && powershell -Command "Get-FileHash * | Format-List > checksums-sha256.txt"
@echo ">>> Контрольные суммы: $(BUILD_DIR)/release/checksums-sha256.txt"
$(info >>> Генерация SHA256...)
Get-FileHash '$(BUILD_DIR)\release\*' | Select-Object Hash, @{N='File';E={$$_.Path | Split-Path -Leaf}} | Format-Table -AutoSize | Out-File -Encoding UTF8 '$(BUILD_DIR)\release\checksums-sha256.txt'
$(info >>> Готово: $(BUILD_DIR)/release/checksums-sha256.txt)
else
checksum: release
$(info >>> Генерация SHA256...)
@cd $(BUILD_DIR)/release && sha256sum $(APP_NAME)-* > checksums-sha256.txt
$(info >>> Готово: $(BUILD_DIR)/release/checksums-sha256.txt)
endif
# ============================================================================
# CI/CD — Управление релизами через Gitea
#
# Пайплайн описан в .gitea/workflows/release.yml
# Запускать ТОЛЬКО через команды make ниже — не вручную.
# ============================================================================
## Проверить git-статус перед созданием тега
ci-check:
$(info >>> Состояние рабочего дерева:)
git status --short
$(info >>> Последние коммиты:)
git log --oneline -5
$(info >>> Убедитесь что все изменения закоммичены перед релизом.)
## Создать git-тег v$(VERSION) и запушить в origin — запускает CI/CD автоматически
## Использование: make release-tag VERSION=2.1.0
release-tag:
ifeq ($(VERSION),2.0.0)
$(info !!! Версия не изменена ($(VERSION)). Укажите: make release-tag VERSION=X.Y.Z)
endif
$(info >>> Создание тега v$(VERSION)...)
git tag -a "v$(VERSION)" -m "Release v$(VERSION)"
git push origin "v$(VERSION)"
$(info >>> Тег v$(VERSION) запушен. CI/CD pipeline запущен автоматически в Gitea.)
$(info >>> Статус: $(GITEA_URL)/$(GITEA_REPO)/actions)
## Удалить ошибочный тег (локально и в origin)
## Использование: make release-tag-delete VERSION=2.1.0
release-tag-delete:
$(info >>> Удаление тега v$(VERSION)...)
-git tag -d "v$(VERSION)"
-git push origin --delete "v$(VERSION)"
$(info >>> Тег v$(VERSION) удалён.)
## Запустить CI/CD workflow вручную через Gitea API (без создания тега)
## Использование: make ci-release VERSION=2.1.0 GITEA_TOKEN=<токен>
ifeq ($(OS),Windows_NT)
ci-release:
$(if $(GITEA_TOKEN),,$(error GITEA_TOKEN не задан. Используйте: make ci-release GITEA_TOKEN=<токен>))
$(info >>> Запуск CI/CD workflow для версии $(VERSION)...)
Invoke-RestMethod -Method Post \
-Uri '$(GITEA_URL)/api/v1/repos/$(GITEA_REPO)/actions/workflows/release.yml/dispatches' \
-Headers @{ Authorization = 'token $(GITEA_TOKEN)'; 'Content-Type' = 'application/json' } \
-Body '{"ref": "main", "inputs": {"version": "$(VERSION)"}}'
$(info >>> Workflow запущен. Статус: $(GITEA_URL)/$(GITEA_REPO)/actions)
else
ci-release:
$(if $(GITEA_TOKEN),,$(error GITEA_TOKEN не задан. Используйте: make ci-release GITEA_TOKEN=<токен>))
$(info >>> Запуск CI/CD workflow для версии $(VERSION)...)
curl -sf -X POST \
-H "Authorization: token $(GITEA_TOKEN)" \
-H "Content-Type: application/json" \
"$(GITEA_URL)/api/v1/repos/$(GITEA_REPO)/actions/workflows/release.yml/dispatches" \
-d '{"ref": "main", "inputs": {"version": "$(VERSION)"}}' \
&& echo "Workflow запущен. Статус: $(GITEA_URL)/$(GITEA_REPO)/actions"
endif
## Полная локальная сборка + архивы + SHA256 (без загрузки в Gitea)
## Результат в build/release/
release-local: checksum
$(info >>> Локальный релиз готов в $(BUILD_DIR)/release/)
# ============================================================================
# Справка
@@ -232,41 +519,49 @@ checksum: release
## Показать справку
help:
@echo ""
@echo " GenAudioBookInfo v$(VERSION) — Makefile"
@echo " ─────────────────────────────────────────"
@echo ""
@echo " Основные:"
@echo " make build Сборка для текущей платформы"
@echo " make build-windows-current Сборка genaudiobookinfo.exe"
@echo " make run Запуск без сборки (workers=8, timeout=60)"
@echo " make run-custom Запуск с параметрами (ARGS=\"-workers 4\")"
@echo " make clean Очистка артефактов сборки"
@echo " make clean-results Очистка директории результатов"
@echo " make clean-all Полная очистка"
@echo ""
@echo " Тестирование:"
@echo " make test Unit-тесты"
@echo " make test-cover Тесты с покрытием кода"
@echo ""
@echo " Качество кода:"
@echo " make fmt Форматирование (go fmt)"
@echo " make vet Статический анализ (go vet)"
@echo " make lint Линтер (golangci-lint)"
@echo " make tidy Обновление go.mod/go.sum"
@echo ""
@echo " Кросс-компиляция:"
@echo " make build-all Все платформы"
@echo " make build-linux Linux: amd64, 386, arm64, armv7"
@echo " make build-darwin macOS: amd64 (Intel), arm64 (Apple Silicon)"
@echo " make build-windows Windows: amd64, 386, arm64"
@echo " make build-arm Embedded: MIPS, MIPSle, RISC-V 64"
@echo " make build-freebsd FreeBSD: amd64, arm64"
@echo " make build-openbsd OpenBSD: amd64"
@echo " make build-netbsd NetBSD: amd64"
@echo ""
@echo " Релиз:"
@echo " make release Сборка для всех платформ"
@echo " make checksum Релиз + SHA256 контрольные суммы"
$(info )
$(info GenAudioBookInfo v$(VERSION) — Makefile)
$(info ─────────────────────────────────────────)
$(info )
$(info Основные:)
$(info make build Сборка для текущей платформы)
$(info make build-windows-current Сборка genaudiobookinfo.exe)
$(info make run Запуск без сборки (workers=8, timeout=60m))
$(info make run-custom ARGS="…" Запуск с параметрами)
$(info make clean Очистка артефактов сборки)
$(info make clean-results Очистка директории результатов)
$(info make clean-all Полная очистка)
$(info )
$(info Тестирование:)
$(info make test Unit-тесты)
$(info make test-cover Тесты с покрытием кода)
$(info )
$(info Качество кода:)
$(info make fmt Форматирование (go fmt))
$(info make vet Статический анализ (go vet))
$(info make lint Линтер (golangci-lint))
$(info make tidy Обновление go.mod/go.sum)
$(info )
$(info Кросс-компиляция:)
$(info make build-all Все платформы (16 бинарников))
$(info make build-linux Linux: amd64, 386, arm64, armv7)
$(info make build-darwin macOS: amd64 (Intel), arm64 (Apple Silicon))
$(info make build-windows Windows: amd64, 386, arm64)
$(info make build-arm Embedded: MIPS, MIPSle, RISC-V 64)
$(info make build-freebsd FreeBSD: amd64, arm64)
$(info make build-openbsd OpenBSD: amd64)
$(info make build-netbsd NetBSD: amd64)
$(info )
$(info Локальный релиз:)
$(info make release-local Все платформы + архивы + SHA256 → build/release/)
$(info make checksum Сборка + SHA256 контрольные суммы)
$(info )
$(info CI/CD Gitea (.gitea/workflows/release.yml):)
$(info make ci-check Проверить git-статус перед релизом)
$(info make release-tag VERSION=X.Y.Z Создать тег и запустить CI автоматически)
$(info make ci-release VERSION=X.Y.Z Запустить CI вручную через Gitea API)
$(info GITEA_TOKEN=<токен> обязателен для ci-release)
$(info make release-tag-delete VERSION=X.Y.Z Удалить ошибочный тег)
$(info )
.DEFAULT_GOAL := help
+161 -61
View File
@@ -9,6 +9,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
@@ -16,51 +17,56 @@ import (
"github.com/fofanov/genaudiobookinfo/internal/infrastructure"
"github.com/fofanov/genaudiobookinfo/internal/presentation"
"github.com/fofanov/genaudiobookinfo/internal/usecase"
"gopkg.in/yaml.v3"
"github.com/joho/godotenv"
)
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"`
In string
Out string
}
TorrAPI struct {
URL string `yaml:"url"`
} `yaml:"torrapi"`
URL string
}
Processing struct {
Workers int
Timeout time.Duration
SearchRetries int
SearchRetryDelay time.Duration
SearchConcurrency int
}
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"`
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-флаги ---
workers := flag.Int("workers", 2, "количество параллельных воркеров")
timeout := flag.Duration("timeout", 5*time.Minute, "таймаут обработки")
apiURL := flag.String("api", "", "адрес TorrAPI сервера (переопределяет config.yaml)")
// --- 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 из config.yaml.")
fmt.Fprintln(os.Stderr, "Если путь не передан, используется DIR_IN из .env.")
fmt.Fprintln(os.Stderr, "Опции:")
flag.PrintDefaults()
fmt.Fprintln(os.Stderr, "")
@@ -69,7 +75,7 @@ func main() {
}
flag.Parse()
cfg := loadConfig("config.yaml")
cfg := loadConfig(".env")
if *showVersion {
fmt.Printf("GenAudioBookInfo v%s\n", version)
@@ -81,14 +87,11 @@ func main() {
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).")
fmt.Fprintln(os.Stderr, "Ошибка: не задан входной каталог (укажите аргумент или DIR_IN в .env).")
os.Exit(1)
}
@@ -104,9 +107,6 @@ func main() {
resultRoot := *resultDir
if resultRoot == "" {
resultRoot = cfg.Dir.Out
if resultRoot == "" {
resultRoot = cfg.OutDir
}
if resultRoot == "" {
resultRoot = filepath.Join(rootPath, "result")
} else {
@@ -114,16 +114,32 @@ func main() {
}
}
// --- Применяем приоритет: 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(), *timeout)
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
fmt.Fprintln(os.Stderr, "\nПрерывание... завершаем работу.")
cancel()
cancel() // TUI завершится сам когда обработка остановится
}()
// --- Composition Root: сборка зависимостей (DI без фреймворка) ---
@@ -136,6 +152,7 @@ func main() {
// Инициализация OpenRouter клиента (если указан API ключ)
var llmClient domain.LLMClient
llmLabel := "выключен (не указан API ключ)"
if cfg.OpenRouter.APIKey != "" {
orClient := infrastructure.NewOpenRouterClient(infrastructure.OpenRouterConfig{
APIKey: cfg.OpenRouter.APIKey,
@@ -152,61 +169,144 @@ func main() {
if modelName == "" {
modelName = "openai/gpt-3.5-turbo"
}
fmt.Printf("OpenRouter: включен (модель: %s)\n", modelName)
} else {
fmt.Printf("OpenRouter: выключен (не указан API ключ)\n")
llmLabel = fmt.Sprintf("включен (%s)", modelName)
}
// --- Выполнение ---
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()
// Получаем список папок для прогресс-бара
// Сканируем список папок до запуска 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)
}
fmt.Printf("Найдено аудиокниг: %d\n\n", len(folders))
// Создаём логгер с прогресс-баром
logger := presentation.NewConsoleLoggerWithProgress(len(folders))
defer logger.Finish()
// --- 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, 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, logger, *workers,
lister, extractor, searcher, writer, downloader, llmClient, tuiLog, procCfg,
)
results, err := processUC.ExecuteForFolders(ctx, folders, resultRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "Ошибка: %v\n", err)
// Канал для передачи результатов из горутины обработки в 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(results)
presenter.RenderProcessResults(outcome.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{}
// 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
if err := yaml.Unmarshal(data, &cfg); err != nil {
return 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
-42
View File
@@ -1,42 +0,0 @@
dir:
in: "./audiobook"
out: "./result"
torrapi:
url: "http://localhost:9200"
openrouter:
api_key: "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" # API ключ для OpenRouter (получить на https://openrouter.ai/keys)
base_url: "https://openrouter.ai/api/v1" # базовый URL API
timeout: 120s # таймаут запросов к API
model: "openai/gpt-4o-mini" # модель по умолчанию (быстрая non-reasoning)
max_retries: 3 # количество повторов при timeout/429
retry_backoff: 1s # стартовая задержка между повторами
retry_backoff_max: 8s # максимальная задержка backoff
prompt: |
Ты — эксперт по метаданным русскоязычных аудиокниг. Твоя задача — проверить и исправить метаданные.
ПРАВИЛА ДЛЯ АВТОРА:
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.
-335
View File
@@ -1,335 +0,0 @@
# OpenRouter Integration
Модуль интеграции с [OpenRouter API](https://openrouter.ai/) для использования различных LLM моделей в проекте GenAudioBookInfo.
## Описание
OpenRouter предоставляет унифицированный API для доступа к различным языковым моделям (GPT-4, Claude, LLaMA и др.) через единый интерфейс, совместимый с OpenAI API.
## Архитектура
Модуль реализован в соответствии с Clean Architecture:
- **Domain Layer** (`internal/domain/`):
- `ports.go` - интерфейс `LLMClient`
- `llm.go` - доменные структуры `LLMRequest`, `LLMResponse`, `LLMMessage`
- **Infrastructure Layer** (`internal/infrastructure/`):
- `openrouter_client.go` - реализация HTTP клиента для OpenRouter API
## Конфигурация
Добавьте настройки в `config.yaml`:
```yaml
openrouter:
api_key: "sk-or-v1-..." # Ваш API ключ с https://openrouter.ai/keys
base_url: "https://openrouter.ai/api/v1" # Базовый URL (по умолчанию)
timeout: 60s # Таймаут запросов
model: "openai/gpt-3.5-turbo" # Модель по умолчанию
prompt: | # System prompt для валидации метаданных (опционально)
Ты — эксперт по метаданным аудиокниг.
Проверяй формат автора (Фамилия Имя) и чистоту названий.
```
### Получение API ключа
1. Зарегистрируйтесь на [openrouter.ai](https://openrouter.ai/)
2. Перейдите в раздел [Keys](https://openrouter.ai/keys)
3. Создайте новый API ключ
4. Добавьте ключ в `config.yaml` или переменную окружения `OPENROUTER_API_KEY`
## Использование
### Базовый пример
```go
package main
import (
"context"
"fmt"
"time"
"github.com/fofanov/genaudiobookinfo/internal/domain"
"github.com/fofanov/genaudiobookinfo/internal/infrastructure"
)
func main() {
// Создание клиента
client := infrastructure.NewOpenRouterClient(infrastructure.OpenRouterConfig{
APIKey: "sk-or-v1-...",
BaseURL: "https://openrouter.ai/api/v1",
Timeout: 60 * time.Second,
Model: "openai/gpt-3.5-turbo",
})
// Создание запроса
req := &domain.LLMRequest{
Model: "openai/gpt-3.5-turbo",
Messages: []domain.LLMMessage{
{
Role: "system",
Content: "Ты — полезный ассистент.",
},
{
Role: "user",
Content: "Привет! Как дела?",
},
},
Temperature: 0.7,
MaxTokens: 100,
}
// Выполнение запроса
ctx := context.Background()
resp, err := client.GenerateCompletion(ctx, req)
if err != nil {
panic(err)
}
// Получение ответа
if len(resp.Choices) > 0 {
fmt.Println(resp.Choices[0].Message.Content)
fmt.Printf("Использовано токенов: %d\n", resp.Usage.TotalTokens)
}
}
```
### Параметры запроса
#### LLMRequest
| Поле | Тип | Описание |
|------|-----|----------|
| `Model` | string | Идентификатор модели (например, `openai/gpt-4`) |
| `Messages` | []LLMMessage | История диалога |
| `Temperature` | float64 | Случайность генерации (0.0 - 2.0) |
| `MaxTokens` | int | Максимум токенов в ответе |
| `TopP` | float64 | Nucleus sampling параметр |
| `FrequencyPenalty` | float64 | Штраф за повторяющиеся токены |
| `PresencePenalty` | float64 | Штраф за уже встречавшиеся токены |
#### LLMMessage
| Поле | Тип | Описание |
|------|-----|----------|
| `Role` | string | Роль: `system`, `user`, или `assistant` |
| `Content` | string | Текст сообщения |
### Доступные модели
OpenRouter поддерживает множество моделей. Популярные варианты:
- **OpenAI**: `openai/gpt-4`, `openai/gpt-3.5-turbo`
- **Anthropic**: `anthropic/claude-3-opus`, `anthropic/claude-3-haiku`
- **Google**: `google/gemini-pro`
- **Meta**: `meta-llama/llama-3-70b-instruct`
- **Mistral**: `mistralai/mistral-7b-instruct`
Полный список: [openrouter.ai/docs#models](https://openrouter.ai/docs#models)
### Примеры использования
#### Анализ метаданных аудиокниги
```go
req := &domain.LLMRequest{
Model: "openai/gpt-4",
Messages: []domain.LLMMessage{
{
Role: "system",
Content: "Ты — эксперт по метаданным аудиокниг.",
},
{
Role: "user",
Content: "Нормализуй автора: Иванов Петр Сергеевич → формат 'Фамилия Имя'",
},
},
Temperature: 0.3,
MaxTokens: 50,
}
```
#### Генерация описания
```go
req := &domain.LLMRequest{
Model: "anthropic/claude-3-haiku",
Messages: []domain.LLMMessage{
{
Role: "system",
Content: "Создавай краткие описания книг на русском языке.",
},
{
Role: "user",
Content: fmt.Sprintf("Создай описание для: %s - %s", author, title),
},
},
Temperature: 0.7,
MaxTokens: 200,
}
```
#### Валидация метаданных
Модуль поддерживает автоматическую валидацию метаданных через метод `ValidateMetadata`:
```go
// Создание клиента с промптом валидации
client := infrastructure.NewOpenRouterClient(infrastructure.OpenRouterConfig{
APIKey: "sk-or-v1-...",
Model: "openai/gpt-3.5-turbo",
Prompt: validationPrompt, // см. config.yaml
})
// Валидация метаданных
result, err := client.ValidateMetadata(ctx, "Иванов Петр Сергеевич", "Путешествие. Книга 1")
if err != nil {
log.Fatal(err)
}
if result != nil {
if result.AuthorFixed {
fmt.Printf("Исправлен автор: %s\n", result.Author) // "Иванов Петр"
}
if result.TitleFixed {
fmt.Printf("Исправлено название: %s\n", result.Title) // "Путешествие"
}
fmt.Printf("Изменения: %s\n", result.Changes)
}
```
**Важно**: Валидация работает только если:
- Указан `api_key` в конфигурации
- Настроен `prompt` в конфигурации
Если `api_key` пустой, метод вернет `nil` и валидация не выполнится.
## Продвинутые возможности
### Контроль таймаутов
```go
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := client.GenerateCompletion(ctx, req)
```
### Обработка ошибок
```go
resp, err := client.GenerateCompletion(ctx, req)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
fmt.Println("Таймаут запроса")
} else {
fmt.Printf("Ошибка API: %v\n", err)
}
return
}
if len(resp.Choices) == 0 {
fmt.Println("Пустой ответ от API")
return
}
```
### Настройка параметров генерации
```go
req := &domain.LLMRequest{
Model: "openai/gpt-3.5-turbo",
Messages: messages,
Temperature: 0.8, // Выше = более креативно
MaxTokens: 500, // Ограничение длины ответа
TopP: 0.9, // Nucleus sampling
FrequencyPenalty: 0.5, // Избегать повторов
PresencePenalty: 0.3, // Разнообразие тем
}
```
## Запуск примеров
В папке `examples/` находятся примеры использования:
### Базовое использование API
```bash
# Установите API ключ
export OPENROUTER_API_KEY="sk-or-v1-..."
# Запустите пример общего использования
go run examples/openrouter_example.go
```
### Валидация метаданных аудиокниг
```bash
# Запустите пример валидации метаданных
go run examples/validate_metadata_example.go
```
Этот пример демонстрирует:
- Исправление порядка "Имя Фамилия" → "Фамилия Имя"
- Удаление отчества из имени автора
- Очистку названий от номеров серий, томов, технических терминов
- Обработку различных вариантов некорректных метаданных
## Стоимость использования
OpenRouter работает по модели pay-as-you-go. Стоимость зависит от выбранной модели:
- GPT-3.5 Turbo: ~$0.002 / 1K токенов
- GPT-4: ~$0.03-0.06 / 1K токенов
- Claude 3 Haiku: ~$0.25 / 1M токенов
Следите за расходами на [openrouter.ai/activity](https://openrouter.ai/activity)
## Безопасность
⚠️ **Важно**: Никогда не коммитьте API ключи в репозиторий!
Используйте:
- Переменные окружения: `OPENROUTER_API_KEY`
- Файл `.env` (добавьте в `.gitignore`)
- Секреты CI/CD систем
## Лимиты и ограничения
- **Rate limiting**: OpenRouter имеет лимиты на количество запросов
- **Timeout**: Рекомендуется устанавливать таймаут 30-60 секунд
- **Max tokens**: Учитывайте лимиты контекста моделей (обычно 4K-128K токенов)
## Устранение неполадок
### Ошибка: "API key не указан"
Проверьте:
1. Ключ указан в `config.yaml`
2. Формат: `api_key: "sk-or-v1-..."`
3. Нет лишних пробелов или кавычек
### Ошибка: HTTP 401 Unauthorized
- Проверьте валидность API ключа на [openrouter.ai/keys](https://openrouter.ai/keys)
- Убедитесь, что ключ активен и не истёк
### Ошибка: HTTP 429 Too Many Requests
- Слишком много запросов, снизьте частоту
- Проверьте лимиты аккаунта
### Таймауты
- Увеличьте `timeout` в конфигурации
- Используйте более быстрые модели (например, `claude-3-haiku`)
- Уменьшите `max_tokens`
## Дополнительные ресурсы
- [OpenRouter Документация](https://openrouter.ai/docs)
- [Список моделей](https://openrouter.ai/docs#models)
- [Цены](https://openrouter.ai/docs#models)
- [API Reference](https://openrouter.ai/docs#api)
-146
View File
@@ -1,146 +0,0 @@
// Package main демонстрирует использование OpenRouter клиента.
// Этот файл является примером и не включён в основной build.
// Для запуска: go run examples/openrouter_example.go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/fofanov/genaudiobookinfo/internal/domain"
"github.com/fofanov/genaudiobookinfo/internal/infrastructure"
)
func main() {
// Получаем API ключ из переменной окружения
apiKey := os.Getenv("OPENROUTER_API_KEY")
if apiKey == "" {
log.Fatal("Ошибка: установите переменную окружения OPENROUTER_API_KEY")
}
// Создаём клиент OpenRouter
client := infrastructure.NewOpenRouterClient(infrastructure.OpenRouterConfig{
APIKey: apiKey,
BaseURL: "https://openrouter.ai/api/v1",
Timeout: 60 * time.Second,
Model: "openai/gpt-3.5-turbo", // модель по умолчанию
Prompt: "", // системный промпт (опционально)
})
// Создаём контекст с таймаутом
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Пример 1: Простой запрос к GPT-3.5
fmt.Println("=== Пример 1: Простой запрос ===")
simpleExample(ctx, client)
fmt.Println("\n=== Пример 2: Анализ метаданных аудиокниги ===")
audiobookExample(ctx, client)
fmt.Println("\n=== Пример 3: Генерация описания книги ===")
descriptionExample(ctx, client)
}
func simpleExample(ctx context.Context, client *infrastructure.OpenRouterClient) {
req := &domain.LLMRequest{
Model: "openai/gpt-3.5-turbo",
Messages: []domain.LLMMessage{
{
Role: "system",
Content: "Ты — полезный ассистент.",
},
{
Role: "user",
Content: "Привет! Расскажи мне коротко, что такое аудиокнига?",
},
},
Temperature: 0.7,
MaxTokens: 200,
}
resp, err := client.GenerateCompletion(ctx, req)
if err != nil {
log.Printf("Ошибка запроса: %v\n", err)
return
}
if len(resp.Choices) > 0 {
fmt.Printf("Ответ: %s\n", resp.Choices[0].Message.Content)
fmt.Printf("Использовано токенов: %d\n", resp.Usage.TotalTokens)
}
}
func audiobookExample(ctx context.Context, client *infrastructure.OpenRouterClient) {
// Пример анализа метаданных аудиокниги
req := &domain.LLMRequest{
Model: "openai/gpt-4o-mini",
Messages: []domain.LLMMessage{
{
Role: "system",
Content: "Ты — эксперт по литературе и аудиокнигам. Анализируй метаданные книг и предлагай улучшения.",
},
{
Role: "user",
Content: `Проанализируй эти метаданные аудиокниги и предложи корректное название:
Автор: Иванов Петр Сергеевич
Название: Путешествие 01
Год: 2023
Предложи правильное форматирование автора (без отчества) и улучшенное название книги.`,
},
},
Temperature: 0.3,
MaxTokens: 300,
}
resp, err := client.GenerateCompletion(ctx, req)
if err != nil {
log.Printf("Ошибка запроса: %v\n", err)
return
}
if len(resp.Choices) > 0 {
fmt.Printf("Анализ:\n%s\n", resp.Choices[0].Message.Content)
fmt.Printf("Модель: %s\n", resp.Model)
fmt.Printf("Использовано токенов: %d\n", resp.Usage.TotalTokens)
}
}
func descriptionExample(ctx context.Context, client *infrastructure.OpenRouterClient) {
// Пример генерации описания по названию книги
req := &domain.LLMRequest{
Model: "anthropic/claude-3-haiku",
Messages: []domain.LLMMessage{
{
Role: "system",
Content: "Ты — литературный критик. Создавай краткие, но информативные описания книг.",
},
{
Role: "user",
Content: `Создай краткое описание (2-3 предложения) для аудиокниги:
Автор: Толстой Лев
Название: Война и мир
Жанр: Исторический роман`,
},
},
Temperature: 0.7,
MaxTokens: 250,
}
resp, err := client.GenerateCompletion(ctx, req)
if err != nil {
log.Printf("Ошибка запроса: %v\n", err)
return
}
if len(resp.Choices) > 0 {
fmt.Printf("Описание:\n%s\n", resp.Choices[0].Message.Content)
fmt.Printf("Причина завершения: %s\n", resp.Choices[0].FinishReason)
fmt.Printf("Токены (запрос/ответ): %d/%d\n",
resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
}
}
-131
View File
@@ -1,131 +0,0 @@
// Package main демонстрирует использование валидации метаданных через OpenRouter.
// Этот файл является примером и не включён в основной build.
// Для запуска: go run examples/validate_metadata_example.go
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/fofanov/genaudiobookinfo/internal/infrastructure"
)
func main() {
// Получаем API ключ из переменной окружения
apiKey := os.Getenv("OPENROUTER_API_KEY")
if apiKey == "" {
log.Fatal("Ошибка: установите переменную окружения OPENROUTER_API_KEY")
}
// Промпт для валидации метаданных
validationPrompt := `Ты — эксперт по метаданным русскоязычных аудиокниг. Твоя задача — проверить и исправить метаданные.
ПРАВИЛА ДЛЯ АВТОРА:
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.`
// Создаём клиент OpenRouter с промптом валидации
client := infrastructure.NewOpenRouterClient(infrastructure.OpenRouterConfig{
APIKey: apiKey,
BaseURL: "https://openrouter.ai/api/v1",
Timeout: 60 * time.Second,
Model: "openai/gpt-3.5-turbo",
Prompt: validationPrompt,
})
// Создаём контекст с таймаутом
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Примеры некорректных метаданных
testCases := []struct {
author string
title string
desc string
}{
{
author: "Иванов Петр Сергеевич",
title: "Путешествие в неизвестность. Книга 1",
desc: "Отчество в авторе, номер книги в названии",
},
{
author: "Александр Пушкин",
title: "Евгений Онегин (Аудиокнига MP3)",
desc: "Имя-Фамилия, служебные слова в названии",
},
{
author: "Лев Николаевич Толстой",
title: "Война и мир. Том 1. 2020",
desc: "Полное имя с отчеством, номер тома и год",
},
{
author: "Стругацкий Аркадий",
title: "Пикник на обочине #2",
desc: "Некорректный номер книги",
},
{
author: "Булгаков Михаил",
title: "Мастер и Маргарита",
desc: "Корректные данные (не требуют исправления)",
},
}
fmt.Println("=== Валидация метаданных аудиокниг ===\n")
for i, tc := range testCases {
fmt.Printf("Пример %d: %s\n", i+1, tc.desc)
fmt.Printf("Исходные данные:\n")
fmt.Printf(" Автор: %s\n", tc.author)
fmt.Printf(" Название: %s\n", tc.title)
result, err := client.ValidateMetadata(ctx, tc.author, tc.title)
if err != nil {
log.Printf(" ❌ Ошибка валидации: %v\n\n", err)
continue
}
if result == nil {
fmt.Println(" ⚠️ Валидация отключена (нет API ключа или промпта)\n")
continue
}
fmt.Printf("\nРезультат:\n")
if result.AuthorFixed || result.TitleFixed {
fmt.Printf(" ✅ Исправлено:\n")
if result.AuthorFixed {
fmt.Printf(" Автор: %s\n", result.Author)
}
if result.TitleFixed {
fmt.Printf(" Название: %s\n", result.Title)
}
fmt.Printf(" 📝 Изменения: %s\n", result.Changes)
} else {
fmt.Printf(" ✓ Данные корректны, исправления не требуются\n")
}
fmt.Println()
}
}
+25 -3
View File
@@ -1,17 +1,39 @@
module github.com/fofanov/genaudiobookinfo
go 1.23
go 1.24.2
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/joho/godotenv v1.5.1
github.com/schollz/progressbar/v3 v3.19.0
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.29.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
+52 -6
View File
@@ -1,13 +1,53 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -18,11 +58,17 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI=
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+3 -1
View File
@@ -66,8 +66,10 @@ type ProcessLogger interface {
LogComplete(folderName string)
// LogError логирует ошибку обработки.
LogError(folderName string, err error)
// LogWarning логирует предупреждение.
// LogWarning логирует предупреждение (неожиданное событие).
LogWarning(message string)
// LogInfo логирует информационное сообщение (штатное событие).
LogInfo(message string)
// Finish завершает работу логгера (например, прогресс-бара).
Finish()
}
+15 -22
View File
@@ -14,6 +14,7 @@ import (
"github.com/dhowden/tag"
"github.com/fofanov/genaudiobookinfo/internal/domain"
"github.com/fofanov/genaudiobookinfo/internal/nameparser"
)
// TagMetadataExtractor реализует domain.MetadataExtractor.
@@ -82,7 +83,7 @@ func (e *TagMetadataExtractor) Extract(ctx context.Context, folderPath string) (
info := &domain.AudioBookInfo{
FolderPath: folderPath,
Title: titleFromTag(title, filepath.Base(folderPath)),
Author: reorderAuthorName(artist),
Author: nameparser.ReorderAuthorName(artist),
Album: album,
Genre: genre,
Year: metadata.Year(),
@@ -116,34 +117,26 @@ func coalesceInt(values ...int) int {
return 0
}
// reorderAuthorName удаляет отчество (если есть) и переставляет:
// "Имя Отчество Фамилия" → "Фамилия Имя", "Имя Фамилия" → "Фамилия Имя".
func reorderAuthorName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return name
}
parts := strings.Fields(name)
if len(parts) >= 3 {
parts = []string{parts[0], parts[len(parts)-1]}
}
if len(parts) == 2 {
return parts[1] + " " + parts[0]
}
return name
}
// titleFromTag возвращает title из MP3 тега, если он содержит кириллицу.
// Если title из тега пуст или не содержит кириллических символов —
// используется имя папки как fallback.
// titleFromTag возвращает title из MP3 тега, если он содержит хотя бы один буквенный символ.
// Если title из тега пуст — используется имя папки как fallback.
func titleFromTag(tagTitle, folderName string) string {
tagTitle = strings.TrimSpace(tagTitle)
if tagTitle != "" && containsCyrillic(tagTitle) {
if tagTitle != "" && hasPrintableText(tagTitle) {
return tagTitle
}
return folderName
}
// hasPrintableText проверяет, содержит ли строка хотя бы одну букву или цифру.
func hasPrintableText(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return true
}
}
return false
}
// containsCyrillic проверяет, содержит ли строка хотя бы один кириллический символ.
func containsCyrillic(s string) bool {
for _, r := range s {
+34 -25
View File
@@ -19,14 +19,14 @@ import (
// OpenRouterClient реализует интерфейс domain.LLMClient для работы с OpenRouter API.
type OpenRouterClient struct {
apiKey string
baseURL string
httpClient *http.Client
defaultModel string
systemPrompt string
maxRetries int
backoffBase time.Duration
backoffMax time.Duration
apiKey string
baseURL string
httpClient *http.Client
defaultModel string
systemPrompt string
maxRetries int
backoffBase time.Duration
backoffMax time.Duration
}
// OpenRouterConfig содержит настройки для OpenRouter клиента.
@@ -37,8 +37,8 @@ type OpenRouterConfig struct {
Model string // модель по умолчанию
Prompt string // system prompt для валидации метаданных
MaxRetries int
RetryBackoff time.Duration
MaxRetries int
RetryBackoff time.Duration
RetryBackoffMax time.Duration
}
@@ -79,14 +79,14 @@ func NewOpenRouterClient(cfg OpenRouterConfig) *OpenRouterClient {
// openRouterRequest соответствует структуре запроса OpenRouter API.
type openRouterRequest struct {
Model string `json:"model"`
Messages []openRouterMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
Stream bool `json:"stream"`
Model string `json:"model"`
Messages []openRouterMessage `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
Stream bool `json:"stream"`
}
type openRouterMessage struct {
@@ -96,11 +96,11 @@ type openRouterMessage struct {
// openRouterResponse соответствует структуре ответа OpenRouter API.
type openRouterResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []openRouterChoice `json:"choices"`
Usage openRouterUsage `json:"usage"`
Error *openRouterError `json:"error,omitempty"`
ID string `json:"id"`
Model string `json:"model"`
Choices []openRouterChoice `json:"choices"`
Usage openRouterUsage `json:"usage"`
Error *openRouterError `json:"error,omitempty"`
}
type openRouterChoice struct {
@@ -236,6 +236,11 @@ func (c *OpenRouterClient) shouldRetry(attempt int, err error, statusCode int) b
return false
}
// Не повторяем при явной отмене контекста
if errors.Is(err, context.Canceled) {
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
@@ -250,7 +255,11 @@ func (c *OpenRouterClient) shouldRetry(attempt int, err error, statusCode int) b
}
func (c *OpenRouterClient) nextBackoff(attempt int, retryAfter time.Duration) time.Duration {
delay := c.backoffBase << attempt
shift := attempt
if shift > 30 {
shift = 30
}
delay := c.backoffBase << shift
if delay <= 0 {
delay = c.backoffBase
}
@@ -305,7 +314,7 @@ func (c *OpenRouterClient) convertRequest(req *domain.LLMRequest) *openRouterReq
if model == "" {
model = c.defaultModel
}
apiReq := &openRouterRequest{
Model: model,
Messages: make([]openRouterMessage, len(req.Messages)),
+54 -227
View File
@@ -10,7 +10,6 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -18,6 +17,7 @@ import (
"unicode/utf8"
"github.com/fofanov/genaudiobookinfo/internal/domain"
"github.com/fofanov/genaudiobookinfo/internal/nameparser"
"github.com/tcolgate/mp3"
)
@@ -77,15 +77,24 @@ func (w *FSResultWriter) WriteResult(ctx context.Context, book *domain.EnrichedB
titleForFolder = "Unknown"
}
// Для папки предпочитаем полный заголовок из detail (серия + название без года).
// metadata.Title может содержать только подзаголовок (после ":"), тогда как
// в папке нужно полное «Серия: Подзаголовок».
if detail != nil && detail.Name != "" {
if fullTitle := nameparser.ExtractFullTitleFromDetailName(detail.Name); fullTitle != "" && containsCyrillic(fullTitle) {
titleForFolder = sanitizePath(fullTitle)
}
}
// --- Fallback: если автор/название — кракозябры, используем данные трекера ---
if detail != nil && detail.Name != "" {
if !containsCyrillic(authorForFolder) || isMojibake(authorForFolder) {
if detailAuthor := extractAuthorFromDetailName(detail.Name); detailAuthor != "" && containsCyrillic(detailAuthor) {
if detailAuthor := nameparser.ExtractAuthorFromDetailName(detail.Name); detailAuthor != "" && containsCyrillic(detailAuthor) {
authorForFolder = sanitizePath(detailAuthor)
}
}
if !containsCyrillic(titleForFolder) || isMojibake(titleForFolder) {
if detailTitle := extractTitleFromDetailName(detail.Name); detailTitle != "" && containsCyrillic(detailTitle) {
if detailTitle := nameparser.ExtractTitleFromDetailName(detail.Name); detailTitle != "" && containsCyrillic(detailTitle) {
titleForFolder = sanitizePath(detailTitle)
}
}
@@ -98,10 +107,22 @@ func (w *FSResultWriter) WriteResult(ctx context.Context, book *domain.EnrichedB
firstLetter = strings.ToUpper(string(r))
}
// Имя папки книги: Автор - Альбом [Год]
bookFolderName := fmt.Sprintf("%s - %s", authorForFolder, titleForFolder)
if metadata.PublishedYear != "" {
bookFolderName = fmt.Sprintf("%s [%s]", bookFolderName, metadata.PublishedYear)
// Имя папки книги: если есть detail — используем нормализованное название раздачи целиком
// (сохраняет оригинальную структуру, включая скобки [...])
var bookFolderName string
if detail != nil && detail.Name != "" {
if normalized := nameparser.NormalizeDetailNameWithAuthorReorder(detail.Name); normalized != "" && containsCyrillic(normalized) {
// Заменяем разделители до sanitizePath (который стрипает '/' и ':')
normalized = strings.ReplaceAll(normalized, " // ", ". ")
normalized = strings.ReplaceAll(normalized, ": ", ". ")
bookFolderName = sanitizePath(normalized)
}
}
if bookFolderName == "" {
bookFolderName = fmt.Sprintf("%s - %s", authorForFolder, titleForFolder)
if metadata.PublishedYear != "" {
bookFolderName = fmt.Sprintf("%s (%s)", bookFolderName, metadata.PublishedYear)
}
}
authorFolder := sanitizePath(authorForFolder)
@@ -111,6 +132,22 @@ func (w *FSResultWriter) WriteResult(ctx context.Context, book *domain.EnrichedB
destDir := filepath.Join(resultRoot, firstLetter, authorFolder, sanitizePath(bookFolderName))
// --- Проверяем дубликат: целевая папка уже существует ---
// Если да — переносим в DUPLICATE/ с той же структурой подпапок.
// Повторный дубликат получает числовой суффикс (_2, _3, …).
if _, err := os.Stat(destDir); err == nil {
baseBookFolder := sanitizePath(bookFolderName)
dupBase := filepath.Join(resultRoot, "DUPLICATE", firstLetter, authorFolder, baseBookFolder)
dupDir := dupBase
for suffix := 2; ; suffix++ {
if _, statErr := os.Stat(dupDir); os.IsNotExist(statErr) {
break
}
dupDir = fmt.Sprintf("%s_%d", dupBase, suffix)
}
destDir = dupDir
}
// --- Создаём целевой каталог только после успешной подготовки данных ---
if err := os.MkdirAll(destDir, 0755); err != nil {
return "", fmt.Errorf("не удалось создать каталог %q: %w", destDir, err)
@@ -168,8 +205,11 @@ func buildMetadata(ctx context.Context, info *domain.AudioBookInfo, detail *doma
}
if detail != nil {
meta.Subtitle = detail.Name
if titleFromDetail := extractTitleFromDetailName(detail.Name); titleFromDetail != "" {
meta.Subtitle = nameparser.NormalizeDetailNameWithAuthorReorder(detail.Name)
if meta.Subtitle == "" {
meta.Subtitle = detail.Name
}
if titleFromDetail := nameparser.ExtractTitleFromDetailName(detail.Name); titleFromDetail != "" {
meta.Title = titleFromDetail
}
@@ -181,92 +221,17 @@ func buildMetadata(ctx context.Context, info *domain.AudioBookInfo, detail *doma
}
if detail.Year != "" {
meta.PublishedYear = detail.Year
} else if extractedYear := nameparser.ExtractYearFromDetailName(detail.Name); extractedYear > 0 {
meta.PublishedYear = strconv.Itoa(extractedYear)
}
meta.Narrators = extractNarrators(detail)
meta.Series = extractSeries(detail.Name)
meta.Narrators = nameparser.ExtractNarrators(detail)
meta.Series = nameparser.ExtractSeries(detail.Name)
}
return meta, nil
}
// extractAuthorFromDetailName извлекает автора из detail.Name.
// Поддерживает формат: "Автор - Название [Год]" → "Автор".
func extractAuthorFromDetailName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
if idx := strings.Index(name, " - "); idx > 0 {
return strings.TrimSpace(name[:idx])
}
return ""
}
// extractTitleFromDetailName извлекает название книги из detail.Name.
// Поддерживает кейс: "Автор - Серия. Книга N [...]" -> "Серия".
func extractTitleFromDetailName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
if idx := strings.Index(name, " - "); idx >= 0 {
name = strings.TrimSpace(name[idx+3:])
}
if idx := strings.Index(name, "["); idx >= 0 {
name = strings.TrimSpace(name[:idx])
}
name = strings.TrimSpace(name)
if name == "" {
return ""
}
// Кейс: "Исекай взрослого человека 03, Вы призвали... Кого надо! #3"
// -> "Исекай взрослого человека"
// Кейс: "Я попал 02, Горячее лето 42-го" -> "Горячее лето 42-го"
if idx := strings.Index(name, ","); idx > 0 {
firstPart := strings.TrimSpace(name[:idx])
secondPart := strings.TrimSpace(name[idx+1:])
reNumAtEnd := regexp.MustCompile(`^(.+?)\s+\d+$`)
// Если вторая часть имеет суффикс #N — это серия,
// а название книги берём из первой части без номера.
reSeriesNumSuffix := regexp.MustCompile(`^(.+?)\s*#\s*\d+\s*$`)
if m := reSeriesNumSuffix.FindStringSubmatch(secondPart); len(m) == 2 {
if mm := reNumAtEnd.FindStringSubmatch(firstPart); len(mm) == 2 {
title := strings.TrimSpace(mm[1])
if title != "" {
return title
}
}
}
// Если первая часть заканчивается номером, а вторая часть обычная,
// то вторая часть — название книги.
if reNumAtEnd.MatchString(firstPart) && secondPart != "" {
return secondPart
}
if m := reNumAtEnd.FindStringSubmatch(firstPart); len(m) == 2 {
title := strings.TrimSpace(m[1])
if title != "" {
return title
}
}
}
// Кейс: "Клан для Антиквара. Книга 1" -> "Клан для Антиквара"
reSeriesBook := regexp.MustCompile(`(?i)^(.+?)\.\s*(?:книга\s*\d+|\d+\s*книга)\s*$`)
if m := reSeriesBook.FindStringSubmatch(name); len(m) == 2 {
title := strings.TrimSpace(m[1])
if title != "" {
return title
}
}
return ""
}
// buildChaptersFromMP3 строит список глав из MP3-файлов в папке.
func buildChaptersFromMP3(ctx context.Context, srcDir string) ([]domain.ChapterInfo, error) {
entries, err := os.ReadDir(srcDir)
if err != nil {
@@ -342,135 +307,6 @@ func readMP3DurationSeconds(filePath string) (float64, error) {
return total.Seconds(), nil
}
func extractNarrators(detail *domain.TorrentDetail) []string {
result := make([]string, 0, 2)
seen := map[string]bool{}
if detail == nil {
return result
}
name := strings.TrimSpace(detail.Name)
if name != "" {
if narrator := narratorFromNameBracket(name); narrator != "" {
if !seen[narrator] {
result = append(result, narrator)
seen[narrator] = true
}
}
}
languageField := strings.TrimSpace(detail.Language)
if languageField != "" {
if !seen[languageField] {
result = append(result, languageField)
seen[languageField] = true
}
}
return result
}
func narratorFromNameBracket(name string) string {
open := strings.LastIndex(name, "[")
close := strings.LastIndex(name, "]")
if open < 0 || close <= open {
return ""
}
inside := strings.TrimSpace(name[open+1 : close])
if inside == "" {
return ""
}
parts := strings.Split(inside, ",")
if len(parts) == 0 {
return ""
}
first := strings.TrimSpace(parts[0])
if first == "" {
return ""
}
// Отсекаем очевидно не имя чтеца
lower := strings.ToLower(first)
if strings.Contains(lower, "kbps") || strings.Contains(lower, "mp3") || strings.Contains(lower, "год") {
return ""
}
return first
}
func extractSeries(name string) []string {
name = strings.TrimSpace(name)
if name == "" {
return []string{}
}
// Берём часть после автора: "Автор - Серия 7, Альбом [...]"
if idx := strings.Index(name, " - "); idx >= 0 {
name = strings.TrimSpace(name[idx+3:])
}
if idx := strings.Index(name, "["); idx >= 0 {
name = strings.TrimSpace(name[:idx])
}
name = strings.TrimSpace(name)
if name == "" {
return []string{}
}
// Кейс: "Исекай взрослого человека 03, Вы призвали... Кого надо! #3"
// -> series: "Вы призвали... Кого надо!"
if idx := strings.Index(name, ","); idx > 0 {
secondPart := strings.TrimSpace(name[idx+1:])
reSeriesNumSuffix := regexp.MustCompile(`^(.+?)\s*#\s*\d+\s*$`)
if m := reSeriesNumSuffix.FindStringSubmatch(secondPart); len(m) == 2 {
series := strings.TrimSpace(m[1])
if series != "" {
return []string{series}
}
}
}
firstPart := name
if idx := strings.Index(firstPart, ","); idx >= 0 {
firstPart = strings.TrimSpace(firstPart[:idx])
}
// Кейс: "Клан для Антиквара. Книга 1" -> "Клан для Антиквара"
reBookSuffix := regexp.MustCompile(`(?i)^(.+?)\s*(?:\(\s*\d+\s*книга\s*\)|[\.,]?\s*книга\s*\d+|[\.,]?\s*\d+\s*книга)\s*$`)
if m := reBookSuffix.FindStringSubmatch(firstPart); len(m) == 2 {
series := strings.TrimSpace(m[1])
series = strings.TrimRight(series, ".,:- ")
if idx := strings.Index(series, ":"); idx >= 0 {
series = strings.TrimSpace(series[:idx])
}
if series != "" {
return []string{series}
}
}
// Кейс: "Первый рыцарь 7, Первый альянс" -> "Первый рыцарь"
reNumAtEnd := regexp.MustCompile(`^(.+?)\s+\d+$`)
if m := reNumAtEnd.FindStringSubmatch(firstPart); len(m) == 2 {
series := strings.TrimSpace(m[1])
if series != "" {
return []string{series}
}
}
// Кейс: "Звёздные беглецы. Миссия Маяк" -> "Звёздные беглецы"
// (серия перед первой точкой, после точки — название конкретной книги)
if dot := strings.Index(name, "."); dot > 0 {
series := strings.TrimSpace(name[:dot])
bookTitle := strings.TrimSpace(name[dot+1:])
if series != "" && bookTitle != "" {
return []string{series}
}
}
return []string{}
}
// moveAudioFiles переносит аудиофайлы из srcDir в destDir.
func moveAudioFiles(ctx context.Context, srcDir, destDir string) error {
entries, err := os.ReadDir(srcDir)
@@ -559,12 +395,3 @@ func splitClean(s string) []string {
}
return result
}
// reorderAuthors применяет reorderAuthorName к каждому автору в списке.
func reorderAuthors(authors []string) []string {
result := make([]string, len(authors))
for i, a := range authors {
result[i] = reorderAuthorName(a)
}
return result
}
+18 -9
View File
@@ -19,9 +19,9 @@ import (
// TorrAPIClient реализует domain.TorrentSearcher.
type TorrAPIClient struct {
baseURL string
httpClient *http.Client
maxRetries int
baseURL string
httpClient *http.Client
maxRetries int
backoffBase time.Duration
backoffMax time.Duration
}
@@ -56,11 +56,16 @@ func (c *TorrAPIClient) SearchByQuery(ctx context.Context, query string) (map[do
}
func (c *TorrAPIClient) searchByRaw(ctx context.Context, query, year string) (map[domain.TrackerName][]domain.TorrentSearchResult, error) {
reqURL := fmt.Sprintf("%s/api/search/title/all?query=%s&page=all&category=0&year=%s&format=0",
c.baseURL,
url.QueryEscape(query),
url.QueryEscape(year),
)
params := url.Values{
"query": {query},
"page": {"all"},
"category": {"0"},
"format": {"0"},
}
if year != "" {
params.Set("year", year)
}
reqURL := c.baseURL + "/api/search/title/all?" + params.Encode()
var body []byte
var lastErr error
@@ -262,7 +267,11 @@ func (c *TorrAPIClient) shouldRetryTorrAPI(attempt int, err error, statusCode in
// nextTorrAPIBackoff вычисляет задержку перед повтором (exponential backoff).
func (c *TorrAPIClient) nextTorrAPIBackoff(attempt int) time.Duration {
delay := c.backoffBase << attempt
shift := attempt
if shift > 30 {
shift = 30
}
delay := c.backoffBase << shift
if delay <= 0 {
delay = c.backoffBase
}
+678
View File
@@ -0,0 +1,678 @@
// Package nameparser содержит логику разбора строк для извлечения
// метаданных аудиокниг: автора, названия, серии, года, чтеца.
//
// Используется как в usecase (разбор имён папок), так и в infrastructure
// (разбор названий раздач с трекеров).
package nameparser
import (
"regexp"
"strconv"
"strings"
"unicode"
"github.com/fofanov/genaudiobookinfo/internal/domain"
)
// ============================================================================
// Регулярные выражения — компилируются один раз при старте программы
// ============================================================================
var (
// Год в квадратных скобках: [2019]
reBracketYear = regexp.MustCompile(`\[(\d{4})\]`)
// Год в круглых скобках: (2022)
reParenYear = regexp.MustCompile(`\((\d{4})\)`)
// Четырёхзначный год внутри любого контекста
reYearInBracket = regexp.MustCompile(`\b(\d{4})\b`)
// Префикс трекера в квадратных скобках: [NNM-Club]_, [RuTracker]
reTrackerPrefix = regexp.MustCompile(`^\[[\w\-\.]+\][_\s]*`)
// Квадратные скобки с содержимым в конце строки: [Чтец, 2023, 128 kbps]
reBracketInfo = regexp.MustCompile(`\[([^\]]+)\]\s*$`)
// Название серии: «Серияname N» или «Название, Книга N»
reNumAtEnd = regexp.MustCompile(`^(.+?)\s+\d+$`)
reSeriesNumSuffix = regexp.MustCompile(`^(.+?)\s*#\s*\d+\s*$`)
reSeriesBook = regexp.MustCompile(`(?i)^(.+?)\.\s*(?:книга\s*\d+|\d+\s*книга)\s*$`)
reBookSuffix = regexp.MustCompile(`(?i)^(.+?)\s*(?:\(\s*\d+\s*книга\s*\)|[\.,]?\s*книга\s*\d+|[\.,]?\s*\d+\s*книга)\s*$`)
// Технический суффикс в конце строки: MP3, МР3, M4B, FLAC ...
reTrailingFormat = regexp.MustCompile(`(?i)\s+(?:mp3|мр3|m4b|ogg|flac|wav|aac)\s*$`)
// Год в круглых скобках в конце строки: "... (2026)"
reTrailingParenYear = regexp.MustCompile(`\s*\((\d{4})\)\s*$`)
// Серия N. Подзаголовок — «Серия» без точек + пробел + число + точка + текст
// Пример: "Моя космическая станция 15. Внутренний конфликт в галактике"
reSeriesNumDot = regexp.MustCompile(`^([^.]+)\s+(\d+)\.\s+(.+)$`)
// «Часть. Том N. Название» — структура внутри серии после двоеточия
// Пример: "Фронтир. Том 3. Бесконечность" → Название="Бесконечность", N=3
reTomNumDot = regexp.MustCompile(`(?i)^(.+)\.\s*(?:том|часть|ч\.)\s*(\d+)\.\s*(.+)$`)
)
// ============================================================================
// Разбор имени папки
// ============================================================================
// FolderInfo — результат разбора имени папки аудиокниги.
type FolderInfo struct {
Author string
Title string
Year int
}
// ParseFolderName разбирает имя папки различных форматов и возвращает
// автора, название (альбом) и год выпуска.
//
// Поддерживаемые форматы:
//
// "Автор - Название [2021]"
// "Автор - Название [Чтец, 2023, 128 kbps, MP3]"
// "[NNM-Club]_Автор_Название (2022)"
// "Автор - Название"
func ParseFolderName(folderName string) (author, title string, year int) {
name := folderName
// 1. Убираем префикс трекера: [NNM-Club]_
name = reTrackerPrefix.ReplaceAllString(name, "")
name = strings.TrimSpace(name)
// 2. Извлекаем год из [2019]
if m := reBracketYear.FindStringSubmatch(name); len(m) == 2 {
year, _ = strconv.Atoi(m[1])
name = strings.Replace(name, m[0], "", 1)
name = strings.TrimSpace(name)
}
// 3. Извлекаем год из (2022)
if year == 0 {
if m := reParenYear.FindStringSubmatch(name); len(m) == 2 {
year, _ = strconv.Atoi(m[1])
name = strings.Replace(name, m[0], "", 1)
name = strings.TrimSpace(name)
}
}
// 4. Обрабатываем [Чтец, 2023, 128 kbps, MP3] в конце строки
if m := reBracketInfo.FindStringSubmatch(name); len(m) == 2 {
bracketContent := m[1]
if year == 0 {
if ym := reYearInBracket.FindStringSubmatch(bracketContent); len(ym) == 2 {
if y, err := strconv.Atoi(ym[1]); err == nil && y >= 1900 && y <= 2100 {
year = y
}
}
}
name = strings.Replace(name, m[0], "", 1)
name = strings.TrimSpace(name)
}
// 5. Убираем технический суффикс формата (MP3, МР3, FLAC…) если остался
name = reTrailingFormat.ReplaceAllString(name, "")
name = strings.TrimSpace(name)
// 6. Основной разделитель " - "
if parts := strings.SplitN(name, " - ", 2); len(parts) == 2 {
author = strings.TrimSpace(parts[0])
title = strings.TrimRight(strings.TrimSpace(parts[1]), ", -")
return
}
// 7. Разделитель "_" (формат NNM-Club)
if parts := strings.SplitN(name, "_", 2); len(parts) == 2 {
author = strings.TrimSpace(parts[0])
title = strings.TrimRight(
strings.TrimSpace(strings.ReplaceAll(parts[1], "_", " ")),
", -",
)
return
}
// 7. Нет разделителя — всё имя считаем названием
title = strings.TrimSpace(name)
return
}
// ParseFolderNameFull — расширенный вариант ParseFolderName, дополнительно
// извлекает чтеца из квадратных скобок.
// Пример: "Автор - Название [Чтец, 2023]" → narrator="Чтец"
func ParseFolderNameFull(folderName string) (author, title string, year int, narrator string) {
name := folderName
// Убираем префикс трекера
name = reTrackerPrefix.ReplaceAllString(name, "")
name = strings.TrimSpace(name)
// Извлекаем год и чтеца из скобок в конце строки
if m := reBracketYear.FindStringSubmatch(name); len(m) == 2 {
year, _ = strconv.Atoi(m[1])
name = strings.Replace(name, m[0], "", 1)
name = strings.TrimSpace(name)
}
if year == 0 {
if m := reParenYear.FindStringSubmatch(name); len(m) == 2 {
year, _ = strconv.Atoi(m[1])
name = strings.Replace(name, m[0], "", 1)
name = strings.TrimSpace(name)
}
}
if m := reBracketInfo.FindStringSubmatch(name); len(m) == 2 {
bracketContent := m[1]
// Пробуем извлечь год
if year == 0 {
if ym := reYearInBracket.FindStringSubmatch(bracketContent); len(ym) == 2 {
if y, err := strconv.Atoi(ym[1]); err == nil && y >= 1900 && y <= 2100 {
year = y
}
}
}
// Пробуем извлечь чтеца — первое поле, разделённое запятой,
// если оно не похоже на технические данные
parts := strings.Split(bracketContent, ",")
if len(parts) > 0 {
candidate := strings.TrimSpace(parts[0])
lower := strings.ToLower(candidate)
if !isTechToken(lower) && len([]rune(candidate)) >= 3 {
narrator = candidate
}
}
name = strings.Replace(name, m[0], "", 1)
name = strings.TrimSpace(name)
}
// Убираем технический суффикс формата (MP3, МР3, FLAC…) если остался
name = reTrailingFormat.ReplaceAllString(name, "")
name = strings.TrimSpace(name)
if parts := strings.SplitN(name, " - ", 2); len(parts) == 2 {
author = strings.TrimSpace(parts[0])
title = strings.TrimRight(strings.TrimSpace(parts[1]), ", -")
return
}
if parts := strings.SplitN(name, "_", 2); len(parts) == 2 {
author = strings.TrimSpace(parts[0])
title = strings.TrimRight(
strings.TrimSpace(strings.ReplaceAll(parts[1], "_", " ")),
", -",
)
return
}
title = strings.TrimSpace(name)
return
}
// isTechToken возвращает true, если строка — технический параметр (kbps, mp3, wav, …).
func isTechToken(s string) bool {
techWords := []string{"kbps", "mp3", "m4b", "ogg", "flac", "wav", "aac", "год", "г.", "cbr", "vbr"}
for _, w := range techWords {
if strings.Contains(s, w) {
return true
}
}
return false
}
// ============================================================================
// Разбор названия раздачи (TorrentDetail.Name)
// ============================================================================
// ExtractAuthorFromDetailName извлекает автора из строки формата
// "Автор - Название [...Год...]".
func ExtractAuthorFromDetailName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
if idx := strings.Index(name, " - "); idx > 0 {
return strings.TrimSpace(name[:idx])
}
return ""
}
// ExtractTitleFromDetailName извлекает чистое название книги из строки
// "Автор - Серия N, Название [Год]".
//
// Поддерживаемые кейсы:
//
// "Автор - Серия 03, Название" → "Название"
// "Автор - Империум. Книга 1 (2026) МР3" → "Империум. Книга 1"
// "Автор - Название [2023]" → "Название"
func ExtractTitleFromDetailName(name string) string {
name = normalizeDetailName(name)
if name == "" {
return ""
}
// Срезаем "Автор - "
if idx := strings.Index(name, " - "); idx >= 0 {
name = strings.TrimSpace(name[idx+3:])
}
// Срезаем скобки в конце "[...]"
if idx := strings.Index(name, "["); idx >= 0 {
name = strings.TrimSpace(name[:idx])
}
// Срезаем год в конце "(2026)"
name = reTrailingParenYear.ReplaceAllString(name, "")
name = strings.TrimSpace(name)
if name == "" {
return ""
}
// Кейс: "Доминион: Фронтир. Том 3. Бесконечность" → "Бесконечность. Книга 3"
if idx := strings.Index(name, ":"); idx >= 0 {
if after := strings.TrimSpace(name[idx+1:]); after != "" {
// Дополнительно разбираем «Часть. Том N. Название» → «Название. Книга N»
if m := reTomNumDot.FindStringSubmatch(after); len(m) == 4 {
return strings.TrimSpace(m[3]) + ". Книга " + m[2]
}
return after
}
}
// Кейс: "Серия N // Название" → "Название. Книга N"
// Пример: "История Российского государства 8 // Дорога в Китеж" → "Дорога в Китеж. Книга 8"
if idx := strings.Index(name, " // "); idx >= 0 {
seriesPart := strings.TrimSpace(name[:idx])
secondPart := strings.TrimSpace(name[idx+4:])
if secondPart != "" {
numStr := ""
if m := reNumAtEnd.FindStringSubmatch(seriesPart); len(m) == 2 {
numStr = strings.TrimSpace(seriesPart[len(m[1]):])
if n, err := strconv.Atoi(numStr); err == nil {
numStr = strconv.Itoa(n)
}
}
if numStr != "" {
return secondPart + ". Книга " + numStr
}
return secondPart
}
}
// Кейс: "Серия N. Название" → "Название. Книга N"
// Пример: "Моя космическая станция 15. Внутренний конфликт в галактике" → "Внутренний конфликт в галактике. Книга 15"
if m := reSeriesNumDot.FindStringSubmatch(name); len(m) == 4 {
return strings.TrimSpace(m[3]) + ". Книга " + m[2]
}
// Кейс: "Серия 03, Название #3" → "Название" (серия с номером + тайтл с #N)
// Кейс: "Серия 02, Название" → "Название"
if idx := strings.Index(name, ","); idx > 0 {
firstPart := strings.TrimSpace(name[:idx])
secondPart := strings.TrimSpace(name[idx+1:])
if m := reSeriesNumSuffix.FindStringSubmatch(secondPart); len(m) == 2 {
if mm := reNumAtEnd.FindStringSubmatch(firstPart); len(mm) == 2 {
if title := strings.TrimSpace(mm[1]); title != "" {
return title
}
}
}
if reNumAtEnd.MatchString(firstPart) && secondPart != "" {
// Извлекаем номер книги из её названия серии: "Хроники Агрида 01" → numStr="1"
numStr := ""
if m2 := reNumAtEnd.FindStringSubmatch(firstPart); len(m2) == 2 {
numStr = strings.TrimSpace(firstPart[len(m2[1]):])
if n, err := strconv.Atoi(numStr); err == nil {
numStr = strconv.Itoa(n)
}
}
if numStr != "" {
return secondPart + ". Книга " + numStr
}
return secondPart
}
if m := reNumAtEnd.FindStringSubmatch(firstPart); len(m) == 2 {
if title := strings.TrimSpace(m[1]); title != "" {
return title
}
}
}
return name
}
// ExtractYearFromDetailName извлекает год из строки вида "... [2023]" или "... (2023)".
func ExtractYearFromDetailName(name string) int {
if m := reBracketYear.FindStringSubmatch(name); len(m) == 2 {
if y, err := strconv.Atoi(m[1]); err == nil {
return y
}
}
if m := reParenYear.FindStringSubmatch(name); len(m) == 2 {
if y, err := strconv.Atoi(m[1]); err == nil {
return y
}
}
return 0
}
// ============================================================================
// Название серии
// ============================================================================
// ExtractSeries извлекает список серий из строки "Автор - Серия N, Альбом [...]".
//
// Поддерживаемые кейсы:
//
// "Автор - Звёздные беглецы 03, Миссия Маяк #3" → ["М иссия Маяк"]
// "Автор - Клан для Антиквара. Книга 1 [...]" → ["Клан для Антиквара"]
// "Автор - Первый рыцарь 7, Первый альянс [...]" → ["Первый рыцарь"]
// "Автор - Звёздные беглецы. Миссия Маяк [...]" → ["Звёздные беглецы"]
func ExtractSeries(name string) []string {
name = normalizeDetailName(name)
if name == "" {
return []string{}
}
// Срезаем "Автор - "
if idx := strings.Index(name, " - "); idx >= 0 {
name = strings.TrimSpace(name[idx+3:])
}
// Срезаем скобки в конце
if idx := strings.Index(name, "["); idx >= 0 {
name = strings.TrimSpace(name[:idx])
}
// Срезаем год в круглых скобках: (2023)
name = reTrailingParenYear.ReplaceAllString(name, "")
name = strings.TrimSpace(name)
if name == "" {
return []string{}
}
// Кейс: «Доминион: Фронтир. Том 3. Бесконечность» → «Доминион»
if idx := strings.Index(name, ":"); idx >= 0 {
if series := strings.TrimSpace(name[:idx]); series != "" {
return []string{series}
}
}
// Кейс: «Серия N // Название» → «Серия» (без номера)
if idx := strings.Index(name, " // "); idx >= 0 {
seriesPart := strings.TrimSpace(name[:idx])
if seriesPart != "" {
if m := reNumAtEnd.FindStringSubmatch(seriesPart); len(m) == 2 {
if s := strings.TrimSpace(m[1]); s != "" {
return []string{s}
}
}
return []string{seriesPart}
}
}
// Кейс: «Серия 03, Название #3» → Серия — вторая часть без #N
if idx := strings.Index(name, ","); idx > 0 {
secondPart := strings.TrimSpace(name[idx+1:])
if m := reSeriesNumSuffix.FindStringSubmatch(secondPart); len(m) == 2 {
if series := strings.TrimSpace(m[1]); series != "" {
return []string{series}
}
}
}
firstPart := name
if idx := strings.Index(firstPart, ","); idx >= 0 {
firstPart = strings.TrimSpace(firstPart[:idx])
}
// Кейс: «Клан для Антиквара. Книга 1»
if m := reBookSuffix.FindStringSubmatch(firstPart); len(m) == 2 {
series := strings.TrimRight(strings.TrimSpace(m[1]), ".,:- ")
if idx := strings.Index(series, ":"); idx >= 0 {
series = strings.TrimSpace(series[:idx])
}
if series != "" {
return []string{series}
}
}
// Кейс: «Первый рыцарь 7, Первый альянс»
if m := reNumAtEnd.FindStringSubmatch(firstPart); len(m) == 2 {
if series := strings.TrimSpace(m[1]); series != "" {
return []string{series}
}
}
// Кейс: «Звёздные беглецы. Миссия Маяк» → «Звёздные беглецы»
// Кейс: «Моя космическая станция 15. Название» → «Моя космическая станция» (число в конце серии срезается)
if dot := strings.Index(name, "."); dot > 0 {
seriesWithNum := strings.TrimSpace(name[:dot])
bookTitle := strings.TrimSpace(name[dot+1:])
if seriesWithNum != "" && bookTitle != "" {
// Срезаем номер книги из конца серии: "Моя космическая станция 15" → "Моя космическая станция"
if m := reNumAtEnd.FindStringSubmatch(seriesWithNum); len(m) == 2 {
return []string{strings.TrimSpace(m[1])}
}
return []string{seriesWithNum}
}
}
return []string{}
}
// NormalizeDetailName убирает служебные технические хвосты из названия раздачи,
// сохраняя содержательное имя книги и год.
// Пример: "Findroid - Империум. Книга 1 (2026) МР3" ->
// "Findroid - Империум. Книга 1 (2026)"
func NormalizeDetailName(name string) string {
return normalizeDetailName(name)
}
// NormalizeDetailNameWithAuthorReorder нормализует название раздачи
// и переставляет автора в формат «Фамилия Имя».
//
// "Игорь Марченко - Доминион: Фронтир. Том 3 (2023)" →
// "Марченко Игорь - Доминион: Фронтир. Том 3 (2023)"
func NormalizeDetailNameWithAuthorReorder(name string) string {
name = normalizeDetailName(name)
if name == "" {
return ""
}
if idx := strings.Index(name, " - "); idx > 0 {
author := strings.TrimSpace(name[:idx])
rest := name[idx:] // включает " - "
return ReorderAuthorName(author) + rest
}
return name
}
// ExtractFullTitleFromDetailName возвращает полный заголовок раздачи (часть после автора,
// без года) — используется для именования результирующей папки.
//
// "Игорь Марченко - Доминион: Фронтир. Том 3. Бесконечность (2023)" →
// "Доминион: Фронтир. Том 3. Бесконечность"
func ExtractFullTitleFromDetailName(name string) string {
name = normalizeDetailName(name)
if name == "" {
return ""
}
if idx := strings.Index(name, " - "); idx >= 0 {
name = strings.TrimSpace(name[idx+3:])
}
if idx := strings.Index(name, "["); idx >= 0 {
name = strings.TrimSpace(name[:idx])
}
name = reTrailingParenYear.ReplaceAllString(name, "")
return strings.TrimSpace(name)
}
// ============================================================================
// Чтец (нарратор)
// ============================================================================
// ExtractNarrators извлекает имена чтецов из детальной информации раздачи.
// Смотрит в поле Name (квадратные скобки в конце) и Language.
func ExtractNarrators(detail *domain.TorrentDetail) []string {
result := make([]string, 0, 2)
seen := map[string]bool{}
if detail == nil {
return result
}
if name := strings.TrimSpace(detail.Name); name != "" {
if narrator := narratorFromBracket(name); narrator != "" && !seen[narrator] {
result = append(result, narrator)
seen[narrator] = true
}
}
if lang := strings.TrimSpace(detail.Language); lang != "" && !seen[lang] {
result = append(result, lang)
seen[lang] = true
}
return result
}
// narratorFromBracket извлекает первое значение из последних квадратных скобок строки,
// если оно не является техническим параметром.
// Пример: "Автор - Название [Чтец, 2023, MP3]" → "Чтец"
func narratorFromBracket(name string) string {
open := strings.LastIndex(name, "[")
close := strings.LastIndex(name, "]")
if open < 0 || close <= open {
return ""
}
inside := strings.TrimSpace(name[open+1 : close])
if inside == "" {
return ""
}
parts := strings.Split(inside, ",")
if len(parts) == 0 {
return ""
}
first := strings.TrimSpace(parts[0])
if first == "" {
return ""
}
if isTechToken(strings.ToLower(first)) {
return ""
}
return first
}
// ============================================================================
// Нормализация автора
// ============================================================================
// ReorderAuthorName приводит имя автора к формату "Фамилия Имя".
// Использует эвристику суффиксов для определения, которое слово является фамилией.
//
// "Иван Петров" → "Петров Иван" (Петров — суффикс -ов)
// "Зубов Константин" → "Зубов Константин" (Зубов уже первым)
// "Андрей Юрьевич Зубов" → "Зубов Андрей" (отчество убирается)
func ReorderAuthorName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return name
}
parts := strings.Fields(name)
if len(parts) >= 3 {
parts = []string{parts[0], parts[len(parts)-1]}
}
if len(parts) == 2 {
first, second := parts[0], parts[1]
firstIsSurname := looksLikeRussianSurname(first)
secondIsSurname := looksLikeRussianSurname(second)
if firstIsSurname && !secondIsSurname {
return first + " " + second
}
if secondIsSurname && !firstIsSurname {
return second + " " + first
}
return first + " " + second
}
return name
}
// ReorderAuthors применяет ReorderAuthorName к каждому элементу списка.
func ReorderAuthors(authors []string) []string {
result := make([]string, len(authors))
for i, a := range authors {
result[i] = ReorderAuthorName(a)
}
return result
}
// BuildAuthorVariants возвращает список поисковых вариантов имени автора.
// Включает оригинал, переставленный порядок и сокращения (без отчества).
func BuildAuthorVariants(author string) []string {
author = strings.TrimSpace(author)
if author == "" {
return []string{author}
}
variants := make([]string, 0, 4)
seen := make(map[string]bool, 4)
add := func(v string) {
v = strings.TrimSpace(v)
if v == "" || seen[v] {
return
}
seen[v] = true
variants = append(variants, v)
}
parts := strings.Fields(author)
add(author)
add(ReorderAuthorName(author))
if len(parts) >= 3 {
short := strings.TrimSpace(parts[0] + " " + parts[len(parts)-1])
add(short)
add(ReorderAuthorName(short))
}
return variants
}
// looksLikeRussianSurname возвращает true, если слово похоже на русскую фамилию
// по характерным суффиксам.
func looksLikeRussianSurname(word string) bool {
word = strings.ToLower(strings.TrimSpace(word))
if word == "" {
return false
}
suffixes := []string{
"ов", "ев", "ёв", "ин", "ын",
"ский", "цкий", "ской",
"ова", "ева", "ёва", "ина", "ына",
"ская", "цкая",
"енко", "ко", "ук", "юк", "ич", "вич",
"дзе", "швили", "ян", "янц", "оглы",
}
for _, suffix := range suffixes {
if strings.HasSuffix(word, suffix) {
return true
}
}
return false
}
// ============================================================================
// Вспомогательные функции
// ============================================================================
// ContainsCyrillic возвращает true, если строка содержит хотя бы один
// кириллический символ.
func ContainsCyrillic(s string) bool {
for _, r := range s {
if unicode.Is(unicode.Cyrillic, r) {
return true
}
}
return false
}
func normalizeDetailName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
name = reTrailingFormat.ReplaceAllString(name, "")
return strings.TrimSpace(name)
}
+11 -1
View File
@@ -138,6 +138,17 @@ func (l *ConsoleLogger) LogWarning(message string) {
}
}
// LogInfo логирует информационное сообщение (штатное событие, не ошибка).
func (l *ConsoleLogger) LogInfo(message string) {
l.mu.Lock()
defer l.mu.Unlock()
if l.bar != nil {
fmt.Fprintf(os.Stderr, "\n ℹ️ %s\n", message)
} else {
fmt.Printf(" ℹ️ %s\n", message)
}
}
// Finish завершает работу progressbar (если есть).
func (l *ConsoleLogger) Finish() {
l.mu.Lock()
@@ -149,4 +160,3 @@ func (l *ConsoleLogger) Finish() {
// Проверка во время компиляции, что ConsoleLogger реализует domain.ProcessLogger
var _ domain.ProcessLogger = (*ConsoleLogger)(nil)
+513
View File
@@ -0,0 +1,513 @@
// Package presentation — Bubbletea TUI-логгер.
// Верхняя панель: прогресс-бар (N/Total книг) + спиннер + последние события.
// Нижняя панель: полный цветной лог всех точек алгоритма.
// Цветовая схема Dracula взята из deploy/main.go.
package presentation
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/fofanov/genaudiobookinfo/internal/domain"
)
// ═══════════════════════════════════════════════════════════════════════
// Цвета и стили (Dracula, из deploy/main.go)
// ═══════════════════════════════════════════════════════════════════════
var (
tuiColorCyan = lipgloss.Color("#00FFFF")
tuiColorYellow = lipgloss.Color("#FFFF00")
tuiColorRed = lipgloss.Color("#FF5555")
tuiColorGreen = lipgloss.Color("#50FA7B")
tuiColorGray = lipgloss.Color("#6272A4")
tuiColorWhite = lipgloss.Color("#F8F8F2")
tuiColorBlue = lipgloss.Color("#6699FF")
tuiStyleInfo = lipgloss.NewStyle().Foreground(tuiColorCyan)
tuiStyleWarn = lipgloss.NewStyle().Foreground(tuiColorYellow).Bold(true)
tuiStyleError = lipgloss.NewStyle().Foreground(tuiColorRed).Bold(true)
tuiStyleSuccess = lipgloss.NewStyle().Foreground(tuiColorGreen)
tuiStyleDebug = lipgloss.NewStyle().Foreground(tuiColorGray)
tuiStyleTime = lipgloss.NewStyle().Foreground(tuiColorGray)
tuiStyleStep = lipgloss.NewStyle().Foreground(tuiColorWhite).Bold(true)
tuiStyleHeader = lipgloss.NewStyle().Foreground(tuiColorBlue).Bold(true).Padding(0, 1)
tuiStyleDone = lipgloss.NewStyle().Foreground(tuiColorGreen).Bold(true)
)
// ═══════════════════════════════════════════════════════════════════════
// Типы
// ═══════════════════════════════════════════════════════════════════════
type tuiLevel int
const (
tuiLevelInfo tuiLevel = iota
tuiLevelWarn
tuiLevelError
tuiLevelSuccess
tuiLevelDebug
)
type tuiLogEntry struct {
Time time.Time
Level tuiLevel
Message string
}
// tuiDoneMsg — отправляется через program.Send когда обработка завершена.
type tuiDoneMsg struct{ err error }
// tuiAutoQuitMsg — отправляется с задержкой для авто-выхода после done.
type tuiAutoQuitMsg struct{}
const (
tuiMaxLogs = 300
tuiRecentCount = 5
tuiTickMs = 80 * time.Millisecond
)
// ═══════════════════════════════════════════════════════════════════════
// Bubbletea model
// ═══════════════════════════════════════════════════════════════════════
type tuiModel struct {
width int
height int
progress progress.Model
spinner spinner.Model
logs []tuiLogEntry
logChan <-chan tuiLogEntry
bookDone <-chan bool // true=ok, false=error — для счётчика
total int
completed int
startTime time.Time
inPath string
outPath string
apiURL string
workers int
done bool
cancelFn func() // вызывается при выходе пользователем во время обработки
}
type tuiTickMsg time.Time
func tuiTick() tea.Cmd {
return tea.Tick(tuiTickMs, func(t time.Time) tea.Msg { return tuiTickMsg(t) })
}
func (m tuiModel) Init() tea.Cmd {
return tea.Batch(
tea.EnterAltScreen,
m.spinner.Tick,
tuiTick(),
)
}
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
if m.cancelFn != nil {
m.cancelFn()
}
return m, tea.Quit
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
pw := m.width - 16
if pw < 20 {
pw = 20
}
m.progress.Width = pw
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
case tuiTickMsg:
m.drain()
cmds = append(cmds, tuiTick())
case tuiDoneMsg:
m.done = true
m.drain() // добираем всё оставшееся
if msg.err != nil {
m.addLog(tuiLogEntry{Time: time.Now(), Level: tuiLevelError,
Message: fmt.Sprintf("Обработка завершена с ошибкой: %v", msg.err)})
} else {
m.addLog(tuiLogEntry{Time: time.Now(), Level: tuiLevelSuccess,
Message: fmt.Sprintf("Все книги обработаны. Итого: %d", m.total)})
}
cmds = append(cmds, tea.Tick(1400*time.Millisecond, func(t time.Time) tea.Msg {
return tuiAutoQuitMsg{}
}))
case tuiAutoQuitMsg:
return m, tea.Quit
}
return m, tea.Batch(cmds...)
}
func (m *tuiModel) drain() {
for {
select {
case e := <-m.logChan:
m.addLog(e)
case <-m.bookDone:
m.completed++
default:
return
}
}
}
func (m *tuiModel) addLog(e tuiLogEntry) {
m.logs = append(m.logs, e)
if len(m.logs) > tuiMaxLogs {
m.logs = m.logs[len(m.logs)-tuiMaxLogs:]
}
}
// ═══════════════════════════════════════════════════════════════════════
// View — верхняя + нижняя панели
// ═══════════════════════════════════════════════════════════════════════
func (m tuiModel) View() string {
topH := m.height / 3
if topH < 10 {
topH = 10
}
botH := m.height - topH - 1
if botH < 3 {
botH = 3
}
return m.renderTop(topH) + m.renderBottom(botH)
}
func (m tuiModel) renderTop(height int) string {
var b strings.Builder
// ── Строка 1: заголовок + время ──────────────────────────────────
elapsed := time.Since(m.startTime).Round(time.Second)
b.WriteString(fmt.Sprintf(" %s %s\n",
tuiStyleHeader.Render("GenAudioBookInfo"),
tuiStyleTime.Render(elapsed.String())))
// ── Строка 2: пути ───────────────────────────────────────────────
b.WriteString(fmt.Sprintf(" %s %s → %s %s\n\n",
tuiStyleDebug.Render("воркеры:"),
tuiStyleDebug.Render(fmt.Sprintf("%d", m.workers)),
tuiStyleDebug.Render(m.inPath),
tuiStyleDebug.Render(m.outPath)))
// ── Строка 3: спиннер + статус ────────────────────────────────────
var statusLine string
if m.done {
statusLine = fmt.Sprintf(" %s %s",
tuiStyleDone.Render("✓"),
tuiStyleDone.Render("Обработка завершена"))
} else {
statusLine = fmt.Sprintf(" %s %s",
m.spinner.View(),
tuiStyleStep.Render("Обработка аудиокниг"))
}
b.WriteString(statusLine + "\n")
// ── Строка 4: прогресс-бар + счётчик ─────────────────────────────
var pct float64
if m.total > 0 {
pct = float64(m.completed) / float64(m.total)
}
bar := m.progress.ViewAs(pct)
countStr := tuiStyleStep.Render(fmt.Sprintf("%d / %d", m.completed, m.total))
pctStr := tuiStyleTime.Render(fmt.Sprintf("%d%%", int(pct*100)))
b.WriteString(fmt.Sprintf(" %s %s %s\n\n", bar, countStr, pctStr))
// ── Строка 5+: последние N событий ───────────────────────────────
b.WriteString(tuiStyleTime.Render(" Последние события:\n"))
start := len(m.logs) - tuiRecentCount
if start < 0 {
start = 0
}
shown := 0
for _, e := range m.logs[start:] {
icon, style := tuiIconStyle(e.Level)
timeStr := e.Time.Format("15:04:05")
msg := tuiTruncate(e.Message, m.width-22)
b.WriteString(fmt.Sprintf(" %s %s %s\n",
style.Render(icon),
tuiStyleTime.Render(timeStr),
style.Render(msg)))
shown++
}
for i := shown; i < tuiRecentCount; i++ {
b.WriteString("\n")
}
// ── Подгонка высоты ──────────────────────────────────────────────
lines := strings.Count(b.String(), "\n")
for lines < height {
b.WriteString("\n")
lines++
}
return b.String()
}
func (m tuiModel) renderBottom(height int) string {
var b strings.Builder
// Разделитель
sep := tuiStyleHeader.Render(" Логи") +
tuiStyleDebug.Render(strings.Repeat("─", max(m.width-10, 10)))
b.WriteString(sep + "\n")
logH := height - 1
if logH < 1 {
logH = 1
}
startIdx := len(m.logs) - logH
if startIdx < 0 {
startIdx = 0
}
shown := 0
for _, e := range m.logs[startIdx:] {
b.WriteString(m.formatLog(e) + "\n")
shown++
}
for i := shown; i < logH; i++ {
b.WriteString("\n")
}
return b.String()
}
func (m tuiModel) formatLog(e tuiLogEntry) string {
timeStr := e.Time.Format("15:04:05")
tag, style := tuiLevelTag(e.Level)
msg := tuiTruncate(e.Message, m.width-25)
return fmt.Sprintf(" %s %s %s",
tuiStyleTime.Render(timeStr),
style.Render(tag),
style.Render(msg))
}
// ═══════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════
func tuiIconStyle(l tuiLevel) (string, lipgloss.Style) {
switch l {
case tuiLevelSuccess:
return "+", tuiStyleSuccess
case tuiLevelWarn:
return "!", tuiStyleWarn
case tuiLevelError:
return "x", tuiStyleError
case tuiLevelDebug:
return "·", tuiStyleDebug
default:
return ">", tuiStyleInfo
}
}
func tuiLevelTag(l tuiLevel) (string, lipgloss.Style) {
switch l {
case tuiLevelInfo:
return "[INFO ]", tuiStyleInfo
case tuiLevelWarn:
return "[WARN ]", tuiStyleWarn
case tuiLevelError:
return "[ERROR]", tuiStyleError
case tuiLevelSuccess:
return "[OK ]", tuiStyleSuccess
case tuiLevelDebug:
return "[DEBUG]", tuiStyleDebug
default:
return "[INFO ]", tuiStyleInfo
}
}
func tuiTruncate(s string, maxLen int) string {
if maxLen <= 0 || len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// ═══════════════════════════════════════════════════════════════════════
// TUILogger — реализует domain.ProcessLogger
// ═══════════════════════════════════════════════════════════════════════
// TUILogger — полноэкранный TUI-логгер на базе Bubbletea.
// Верхняя панель: прогресс-бар N/Total + спиннер + последние события.
// Нижняя панель: подробный хронологический лог всего процесса.
type TUILogger struct {
logChan chan tuiLogEntry
bookDoneCh chan bool
program *tea.Program
total int
inPath string
outPath string
apiURL string
workers int
}
// NewTUILogger создаёт TUILogger.
func NewTUILogger(total int, inPath, outPath, apiURL string, workers int) *TUILogger {
return &TUILogger{
logChan: make(chan tuiLogEntry, 500),
bookDoneCh: make(chan bool, 200),
total: total,
inPath: inPath,
outPath: outPath,
apiURL: apiURL,
workers: workers,
}
}
// Run запускает TUI (блокирует текущую горутину).
// cancelFn вызывается при нажатии q / Ctrl+C пользователем.
func (l *TUILogger) Run(cancelFn func()) {
pw := 60
prog := progress.New(
progress.WithDefaultGradient(),
progress.WithWidth(pw),
progress.WithoutPercentage(),
)
spin := spinner.New()
spin.Spinner = spinner.Dot
spin.Style = lipgloss.NewStyle().Foreground(tuiColorCyan)
m := tuiModel{
width: 120,
height: 40,
progress: prog,
spinner: spin,
logs: make([]tuiLogEntry, 0, tuiMaxLogs),
logChan: l.logChan,
bookDone: l.bookDoneCh,
total: l.total,
startTime: time.Now(),
inPath: l.inPath,
outPath: l.outPath,
apiURL: l.apiURL,
workers: l.workers,
cancelFn: cancelFn,
}
p := tea.NewProgram(m,
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
)
l.program = p
_, _ = p.Run()
}
// SetDone сигнализирует TUI об окончании обработки.
func (l *TUILogger) SetDone(err error) {
if l.program != nil {
l.program.Send(tuiDoneMsg{err: err})
}
}
// send отправляет запись в лог-канал (non-blocking).
func (l *TUILogger) send(level tuiLevel, msg string) {
e := tuiLogEntry{Time: time.Now(), Level: level, Message: msg}
select {
case l.logChan <- e:
default:
}
}
// ═══════════════════════════════════════════════════════════════════════
// domain.ProcessLogger — все точки алгоритма
// ═══════════════════════════════════════════════════════════════════════
// LogStart — начало обработки книги.
func (l *TUILogger) LogStart(folderName string) {
l.send(tuiLevelInfo, fmt.Sprintf("[%s] ▶ Начало обработки", folderName))
}
// LogExtraction — шаг 1: извлечение тегов из аудиофайла.
func (l *TUILogger) LogExtraction() {
l.send(tuiLevelDebug, " → [Шаг 1] Извлечение тегов...")
}
// LogLLMValidation — шаг 3: LLM-валидация метаданных.
func (l *TUILogger) LogLLMValidation() {
l.send(tuiLevelDebug, " → [Шаг 3] LLM: отправка запроса на валидацию...")
}
// LogSearch — шаг 2: разбор имени папки и поиск на трекерах.
func (l *TUILogger) LogSearch() {
l.send(tuiLevelDebug, " → [Шаг 2] Поиск на трекерах через TorrAPI...")
}
// LogWrite — шаг 4: запись метаданных и перенос файлов.
func (l *TUILogger) LogWrite() {
l.send(tuiLevelDebug, " → [Шаг 4] Создание metadata.json + перенос файлов...")
}
// LogCoverDownload — шаг 5: скачивание обложки.
func (l *TUILogger) LogCoverDownload() {
l.send(tuiLevelDebug, " → [Шаг 5] Скачивание обложки (poster)...")
}
// LogComplete — книга обработана успешно.
func (l *TUILogger) LogComplete(folderName string) {
l.send(tuiLevelSuccess, fmt.Sprintf("[%s] ✓ Обработано успешно", folderName))
select {
case l.bookDoneCh <- true:
default:
}
}
// LogError — ошибка при обработке книги.
func (l *TUILogger) LogError(folderName string, err error) {
l.send(tuiLevelError, fmt.Sprintf("[%s] ✗ Ошибка: %v", folderName, err))
select {
case l.bookDoneCh <- false:
default:
}
}
// LogWarning — предупреждение (неожиданное, но не критичное событие).
func (l *TUILogger) LogWarning(message string) {
l.send(tuiLevelWarn, message)
}
// LogInfo — штатное информационное событие.
func (l *TUILogger) LogInfo(message string) {
l.send(tuiLevelInfo, message)
}
// Finish — завершение логгера (TUI завершается через SetDone/автовыход).
func (l *TUILogger) Finish() {}
// Compile-time check: TUILogger должен реализовывать domain.ProcessLogger.
var _ domain.ProcessLogger = (*TUILogger)(nil)
+241 -259
View File
@@ -10,15 +10,41 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/fofanov/genaudiobookinfo/internal/domain"
"github.com/fofanov/genaudiobookinfo/internal/nameparser"
)
// ProcessingConfig содержит настройки конвейера обработки.
// Все поля читаются из секции processing: в config.yaml.
type ProcessingConfig struct {
Workers int // кол-во параллельных воркеров
SearchRetries int // кол-во попыток поиска книги на трекерах
SearchRetryDelay time.Duration // пауза между попытками
SearchConcurrency int // макс. параллельных запросов к TorrAPI
}
// defaults применяет значения по умолчанию для нулевых полей.
func (c *ProcessingConfig) defaults() {
if c.Workers <= 0 {
c.Workers = 2
}
if c.SearchRetries <= 0 {
c.SearchRetries = 3
}
if c.SearchRetryDelay <= 0 {
c.SearchRetryDelay = 3 * time.Second
}
if c.SearchConcurrency <= 0 {
c.SearchConcurrency = 2
}
}
// ProcessAudioBooksUseCase — основной интерактор приложения.
type ProcessAudioBooksUseCase struct {
lister domain.FolderLister
@@ -26,9 +52,10 @@ type ProcessAudioBooksUseCase struct {
searcher domain.TorrentSearcher
writer domain.ResultWriter
downloader domain.CoverDownloader
llm domain.LLMClient // может быть nil, если API ключ не указан
llm domain.LLMClient // может быть nil, если API ключ не указан
logger domain.ProcessLogger // может быть nil, если не настроено
workers int
cfg ProcessingConfig
searchSem chan struct{} // ограничивает число параллельных запросов к TorrAPI
}
// NewProcessAudioBooksUseCase создаёт usecase с инъекцией зависимостей.
@@ -40,11 +67,9 @@ func NewProcessAudioBooksUseCase(
downloader domain.CoverDownloader,
llm domain.LLMClient,
logger domain.ProcessLogger,
workers int,
cfg ProcessingConfig,
) *ProcessAudioBooksUseCase {
if workers <= 0 {
workers = 4
}
cfg.defaults()
return &ProcessAudioBooksUseCase{
lister: lister,
extractor: extractor,
@@ -53,7 +78,8 @@ func NewProcessAudioBooksUseCase(
downloader: downloader,
llm: llm,
logger: logger,
workers: workers,
cfg: cfg,
searchSem: make(chan struct{}, cfg.SearchConcurrency),
}
}
@@ -98,10 +124,10 @@ func (uc *ProcessAudioBooksUseCase) ExecuteForFolders(ctx context.Context, folde
}()
// --- Worker Pool ---
results := make(chan domain.ProcessResult, len(folders))
results := make(chan domain.ProcessResult, uc.cfg.Workers)
var wg sync.WaitGroup
for i := 0; i < uc.workers; i++ {
for i := 0; i < uc.cfg.Workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
@@ -134,13 +160,13 @@ func (uc *ProcessAudioBooksUseCase) ExecuteForFolders(ctx context.Context, folde
// processOneBook обрабатывает одну аудиокнигу целиком.
func (uc *ProcessAudioBooksUseCase) processOneBook(ctx context.Context, folderPath, resultRoot string) domain.ProcessResult {
folderName := filepath.Base(folderPath)
// Логируем начало обработки
if uc.logger != nil {
uc.logger.LogStart(folderName)
}
// Шаг 2: извлекаем метаданные из первого аудиофайла
// Шаг 1: извлекаем метаданные из первого аудиофайла
if uc.logger != nil {
uc.logger.LogExtraction()
}
@@ -156,25 +182,7 @@ func (uc *ProcessAudioBooksUseCase) processOneBook(ctx context.Context, folderPa
AudioBook: info,
}
// Шаг 2.5: валидация метаданных через LLM (если настроено)
if uc.llm != nil && info.Author != "" && info.Title != "" {
if uc.logger != nil {
uc.logger.LogLLMValidation()
}
validatedAuthor, validatedTitle := uc.validateWithLLM(ctx, info.Author, info.Title)
if validatedAuthor != "" {
info.Author = validatedAuthor
}
if validatedTitle != "" {
oldTitle := info.Title
info.Title = validatedTitle
if info.Album == "" || info.Album == oldTitle {
info.Album = validatedTitle
}
}
}
// Шаг 3: поиск на трекерах
// Шаг 2: поиск на трекерах
if uc.logger != nil {
uc.logger.LogSearch()
}
@@ -186,8 +194,8 @@ func (uc *ProcessAudioBooksUseCase) processOneBook(ctx context.Context, folderPa
// Если метаданные из тегов пустые — парсим название папки
if author == "" || album == "" {
folderAuthor, folderAlbum, folderYear := parseFolderName(filepath.Base(folderPath))
folderAuthor = reorderAuthorName(folderAuthor)
folderAuthor, folderAlbum, folderYear := nameparser.ParseFolderName(filepath.Base(folderPath))
folderAuthor = nameparser.ReorderAuthorName(folderAuthor)
if author == "" {
author = folderAuthor
if info.Author == "" {
@@ -208,50 +216,98 @@ func (uc *ProcessAudioBooksUseCase) processOneBook(ctx context.Context, folderPa
}
}
if author != "" && album != "" {
detail, trackerName, searchErr := uc.searchOnTrackers(ctx, author, album, info.Year)
if searchErr != nil {
// Поиск не удался — продолжаем без данных трекера
enriched.ErrorMessage = fmt.Sprintf("поиск на трекерах: %v", searchErr)
// Шаг 3: LLM-валидация метаданных (если настроено).
// Запускается ПОСЛЕ парсинга имени папки, чтобы всегда иметь данные для валидации.
if uc.llm != nil {
llmTitle := info.Title
if llmTitle == "" {
llmTitle = album
}
if author != "" && llmTitle != "" {
if uc.logger != nil {
uc.logger.LogWarning(fmt.Sprintf("Поиск не дал результата: %v", searchErr))
uc.logger.LogLLMValidation()
}
} else if detail != nil {
enriched.Detail = detail
enriched.TrackerName = trackerName
}
}
// Fallback: если по author/album ничего не нашли — пробуем сырое имя папки
if enriched.Detail == nil {
folderQuery := strings.TrimSpace(filepath.Base(folderPath))
if folderQuery != "" {
detail, trackerName, err := uc.searchOnTrackersByFolderQuery(ctx, folderQuery)
if err != nil {
if enriched.ErrorMessage == "" {
enriched.ErrorMessage = fmt.Sprintf("поиск на трекерах: %v", err)
} else {
enriched.ErrorMessage = fmt.Sprintf("%s; fallback по имени папки: %v", enriched.ErrorMessage, err)
validatedAuthor, validatedTitle := uc.validateWithLLM(ctx, author, llmTitle)
if validatedAuthor != "" {
info.Author = validatedAuthor
author = validatedAuthor
}
if validatedTitle != "" {
oldTitle := info.Title
info.Title = validatedTitle
if info.Album == "" || info.Album == oldTitle {
info.Album = validatedTitle
}
} else if detail != nil {
enriched.Detail = detail
enriched.TrackerName = trackerName
album = validatedTitle
}
}
}
// Если данных трекеров нет — не переносим папку в result
if enriched.Detail == nil {
err := fmt.Errorf("[%s] данные трекеров не найдены, перенос пропущен", folderPath)
// --- Поиск на трекерах с повторными попытками ---
// Каждый раз выполняется полный цикл: поиск по метаданным + fallback по имени папки.
// При неудаче ждём retryDelay и повторяем. После maxSearchRetries папка уходит в ERROR/.
var searchDetail *domain.TorrentDetail
var searchTracker domain.TrackerName
var lastSearchErrMsg string
for attempt := 1; attempt <= uc.cfg.SearchRetries; attempt++ {
if attempt > 1 {
if uc.logger != nil {
uc.logger.LogWarning(fmt.Sprintf(
"[%s] Попытка %d/%d через %s...",
folderName, attempt, uc.cfg.SearchRetries, uc.cfg.SearchRetryDelay))
}
select {
case <-time.After(uc.cfg.SearchRetryDelay):
case <-ctx.Done():
return domain.ProcessResult{Book: enriched, Err: ctx.Err()}
}
if uc.logger != nil {
uc.logger.LogSearch()
}
}
var errMsg string
searchDetail, searchTracker, errMsg = uc.findBookOnTrackers(ctx, author, album, folderPath, info.Year)
if searchDetail != nil {
break
}
if ctx.Err() != nil {
return domain.ProcessResult{Book: enriched, Err: ctx.Err()}
}
lastSearchErrMsg = errMsg
if uc.logger != nil {
uc.logger.LogError(folderName, err)
uc.logger.LogWarning(fmt.Sprintf(
"[%s] Попытка %d/%d не дала результата: %s",
folderName, attempt, uc.cfg.SearchRetries, errMsg))
}
}
if searchDetail == nil {
// Все попытки исчерпаны — переносим папку в ERROR/
reason := fmt.Sprintf(
"Не найдено на трекерах после %d попыток. Последняя ошибка: %s",
uc.cfg.SearchRetries, lastSearchErrMsg)
enriched.ErrorMessage = reason
if uc.logger != nil {
uc.logger.LogError(folderName, fmt.Errorf("%s", reason))
}
if moveErr := uc.moveToErrorFolder(folderPath, resultRoot, reason); moveErr != nil {
if uc.logger != nil {
uc.logger.LogWarning(fmt.Sprintf("[%s] Перенос в ERROR не удался: %v", folderName, moveErr))
}
} else if uc.logger != nil {
uc.logger.LogInfo(fmt.Sprintf("[%s] Папка перенесена в ERROR/", folderName))
}
return domain.ProcessResult{
Book: enriched,
Err: err,
Err: fmt.Errorf("[%s] %s", folderName, reason),
}
}
enriched.Detail = searchDetail
enriched.TrackerName = searchTracker
// Шаг 4: создаём результат (metadata.json + перенос файлов)
if uc.logger != nil {
uc.logger.LogWrite()
@@ -293,11 +349,84 @@ func (uc *ProcessAudioBooksUseCase) processOneBook(ctx context.Context, folderPa
}
}
// findBookOnTrackers выполняет одну попытку поиска книги:
// сначала по автору + альбому, затем fallback по сырому имени папки.
// Возвращает detail, trackerName и строку с описанием ошибки (пустую при успехе).
func (uc *ProcessAudioBooksUseCase) findBookOnTrackers(
ctx context.Context, author, album, folderPath string, year int,
) (*domain.TorrentDetail, domain.TrackerName, string) {
var errParts []string
if author != "" && album != "" {
detail, tracker, err := uc.searchOnTrackers(ctx, author, album, year)
if err != nil {
errParts = append(errParts, fmt.Sprintf("по метаданным: %v", err))
} else if detail != nil {
return detail, tracker, ""
} else {
errParts = append(errParts, "по метаданным: нет результатов")
}
}
// Fallback: по сырому имени папки
if folderQuery := strings.TrimSpace(filepath.Base(folderPath)); folderQuery != "" {
detail, tracker, err := uc.searchOnTrackersByFolderQuery(ctx, folderQuery)
if err != nil {
errParts = append(errParts, fmt.Sprintf("по имени папки: %v", err))
} else if detail != nil {
return detail, tracker, ""
} else {
errParts = append(errParts, "по имени папки: нет результатов")
}
}
return nil, "", strings.Join(errParts, "; ")
}
// moveToErrorFolder переносит папку книги в resultRoot/ERROR/<folderName>.
// Перед переносом записывает _error.txt с причиной и временем ошибки.
// Если в ERROR уже есть папка с таким именем — добавляет временной суффикс.
func (uc *ProcessAudioBooksUseCase) moveToErrorFolder(folderPath, resultRoot, reason string) error {
errorRoot := filepath.Join(resultRoot, "ERROR")
if err := os.MkdirAll(errorRoot, 0755); err != nil {
return fmt.Errorf("создание ERROR/: %w", err)
}
folderName := filepath.Base(folderPath)
dest := filepath.Join(errorRoot, folderName)
// Если dest уже занят — добавляем временной суффикс
if _, err := os.Stat(dest); err == nil {
dest = dest + "_" + time.Now().Format("20060102_150405")
}
// Записываем причину в _error.txt внутри исходной папки (до переноса)
_ = os.WriteFile(
filepath.Join(folderPath, "_error.txt"),
[]byte(fmt.Sprintf("%s\n\nВремя: %s\n", reason, time.Now().Format("2006-01-02 15:04:05"))),
0644,
)
if err := os.Rename(folderPath, dest); err != nil {
return fmt.Errorf("os.Rename %q → ERROR/: %w", folderName, err)
}
return nil
}
// searchOnTrackers перебирает трекеры в порядке приоритета.
// Для каждого трекера: берёт первый результат → запрашивает детали.
// Если трекер не дал результата или произошла ошибка — переходим к следующему.
// searchSem ограничивает параллельные запросы к TorrAPI (защита от перегрузки).
func (uc *ProcessAudioBooksUseCase) searchOnTrackers(ctx context.Context, author, album string, year int) (*domain.TorrentDetail, domain.TrackerName, error) {
authorVariants := buildAuthorVariants(author)
// захватываем семафор: блокируемся, если достигнут лимита maxConcurrentSearches
select {
case uc.searchSem <- struct{}{}:
defer func() { <-uc.searchSem }()
case <-ctx.Done():
return nil, "", ctx.Err()
}
authorVariants := nameparser.BuildAuthorVariants(author)
searchYears := []int{year}
if year > 0 {
searchYears = append(searchYears, 0)
@@ -323,18 +452,20 @@ func (uc *ProcessAudioBooksUseCase) searchOnTrackers(ctx context.Context, author
continue
}
// Берём первый результат
firstID := items[0].ID
if firstID == "" {
continue
// Берём первый результат, пропуская FB2-раздачи
for _, item := range items {
if isFB2Result(item.Name) {
continue
}
if item.ID == "" {
continue
}
detail, detailErr := uc.searcher.GetDetailByID(ctx, tracker, item.ID)
if detailErr != nil {
continue
}
return detail, tracker, nil
}
detail, detailErr := uc.searcher.GetDetailByID(ctx, tracker, firstID)
if detailErr != nil {
continue
}
return detail, tracker, nil
}
}
}
@@ -347,6 +478,14 @@ func (uc *ProcessAudioBooksUseCase) searchOnTrackers(ctx context.Context, author
}
func (uc *ProcessAudioBooksUseCase) searchOnTrackersByFolderQuery(ctx context.Context, query string) (*domain.TorrentDetail, domain.TrackerName, error) {
// захватываем семафор: блокируемся, если достигнут лимита maxConcurrentSearches
select {
case uc.searchSem <- struct{}{}:
defer func() { <-uc.searchSem }()
case <-ctx.Done():
return nil, "", ctx.Err()
}
searchResults, err := uc.searcher.SearchByQuery(ctx, query)
if err != nil {
return nil, "", err
@@ -362,192 +501,35 @@ func (uc *ProcessAudioBooksUseCase) searchOnTrackersByFolderQuery(ctx context.Co
continue
}
firstID := items[0].ID
if firstID == "" {
continue
// Берём первый результат, пропуская FB2-раздачи
for _, item := range items {
if isFB2Result(item.Name) {
continue
}
if item.ID == "" {
continue
}
detail, detailErr := uc.searcher.GetDetailByID(ctx, tracker, item.ID)
if detailErr != nil {
continue
}
return detail, tracker, nil
}
detail, detailErr := uc.searcher.GetDetailByID(ctx, tracker, firstID)
if detailErr != nil {
continue
}
return detail, tracker, nil
}
return nil, "", fmt.Errorf("не удалось получить детали ни с одного трекера для query=%q", query)
}
// Регулярные выражения для парсинга имени папки.
var (
// Год в квадратных скобках: [2019]
reBracketYear = regexp.MustCompile(`\[(\d{4})\]`)
// Год в круглых скобках: (2022)
reParenYear = regexp.MustCompile(`\((\d{4})\)`)
// Год внутри квадратных скобок с другими данными: [Чтец, 2023, 128 kbps, MP3]
reYearInBracket = regexp.MustCompile(`\b(\d{4})\b`)
// Префикс трекера в квадратных скобках: [NNM-Club]
reTrackerPrefix = regexp.MustCompile(`^\[[\w\-\.]+\][_\s]*`)
// Квадратные скобки с содержимым (не чистый год): [Чтец, 2023, 128 kbps]
reBracketInfo = regexp.MustCompile(`\[([^\]]+)\]\s*$`)
)
// parseFolderName, reorderAuthorName, looksLikeRussianSurname, buildAuthorVariants
// перенесены в пакет internal/nameparser.
// parseFolderName разбирает имя папки различных форматов:
//
// "Автор - Название [Год]"
// "[NNM-Club]_Автор_Название (Год)"
// "Автор - Название [Чтец]"
// "Автор - Название [Чтец, Год, bitrate, формат]"
//
// Возвращает author, album, year.
func parseFolderName(folderName string) (string, string, int) {
name := folderName
// 1. Убираем префикс трекера: [NNM-Club]_
name = reTrackerPrefix.ReplaceAllString(name, "")
name = strings.TrimSpace(name)
year := 0
// 2. Извлекаем год из квадратных скобок, если там только год: [2019]
if m := reBracketYear.FindStringSubmatch(name); len(m) == 2 {
year, _ = strconv.Atoi(m[1])
name = strings.Replace(name, m[0], "", 1)
name = strings.TrimSpace(name)
}
// 3. Извлекаем год из круглых скобок: (2022)
if year == 0 {
if m := reParenYear.FindStringSubmatch(name); len(m) == 2 {
year, _ = strconv.Atoi(m[1])
name = strings.Replace(name, m[0], "", 1)
name = strings.TrimSpace(name)
}
}
// 4. Обрабатываем квадратные скобки с доп. информацией: [Чтец, 2023, 128 kbps, MP3]
if m := reBracketInfo.FindStringSubmatch(name); len(m) == 2 {
bracketContent := m[1]
// Пытаемся извлечь год из содержимого скобок
if year == 0 {
if ym := reYearInBracket.FindStringSubmatch(bracketContent); len(ym) == 2 {
y, _ := strconv.Atoi(ym[1])
if y >= 1900 && y <= 2100 {
year = y
}
}
}
// Убираем скобки с содержимым из имени
name = strings.Replace(name, m[0], "", 1)
name = strings.TrimSpace(name)
}
// 5. Пробуем разделить по " - " (основной формат)
if parts := strings.SplitN(name, " - ", 2); len(parts) == 2 {
author := strings.TrimSpace(parts[0])
album := strings.TrimSpace(parts[1])
album = strings.TrimRight(album, ", -")
album = strings.TrimSpace(album)
return author, album, year
}
// 6. Пробуем разделить по "_" (формат NNM-Club)
if parts := strings.SplitN(name, "_", 2); len(parts) == 2 {
author := strings.TrimSpace(parts[0])
album := strings.TrimSpace(parts[1])
album = strings.ReplaceAll(album, "_", " ")
album = strings.TrimRight(album, ", -")
album = strings.TrimSpace(album)
return author, album, year
}
// 7. Нет разделителя — всё имя считаем названием
return "", strings.TrimSpace(name), year
}
// reorderAuthorName приводит автора к формату "Фамилия Имя".
// Если вход уже похож на "Фамилия Имя" (например, "Зубов Константин"),
// порядок слов не меняется.
func reorderAuthorName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return name
}
parts := strings.Fields(name)
if len(parts) >= 3 {
parts = []string{parts[0], parts[len(parts)-1]}
}
if len(parts) == 2 {
first := parts[0]
second := parts[1]
firstIsSurname := looksLikeRussianSurname(first)
secondIsSurname := looksLikeRussianSurname(second)
if firstIsSurname && !secondIsSurname {
return first + " " + second
}
if secondIsSurname && !firstIsSurname {
return second + " " + first
}
return first + " " + second
}
return name
}
func looksLikeRussianSurname(word string) bool {
word = strings.ToLower(strings.TrimSpace(word))
if word == "" {
return false
}
suffixes := []string{
"ов", "ев", "ёв", "ин", "ын",
"ский", "цкий", "ской",
"ова", "ева", "ёва", "ина", "ына",
"ская", "цкая", "ская",
"енко", "ко", "ук", "юк", "ич", "вич",
"дзе", "швили", "ян", "янц", "оглы",
}
for _, suffix := range suffixes {
if strings.HasSuffix(word, suffix) {
return true
}
}
return false
}
func buildAuthorVariants(author string) []string {
author = strings.TrimSpace(author)
if author == "" {
return []string{author}
}
variants := make([]string, 0, 4)
seen := make(map[string]bool, 4)
add := func(v string) {
v = strings.TrimSpace(v)
if v == "" || seen[v] {
return
}
seen[v] = true
variants = append(variants, v)
}
parts := strings.Fields(author)
add(author)
add(reorderAuthorName(author))
if len(parts) >= 3 {
short := strings.TrimSpace(parts[0] + " " + parts[len(parts)-1])
add(short)
add(reorderAuthorName(short))
}
return variants
// isFB2Result возвращает true, если название раздачи относится к электронной книге FB2
// (а не к аудиокниге). Такие раздачи нужно пропускать.
func isFB2Result(name string) bool {
nameLower := strings.ToLower(name)
return strings.Contains(nameLower, "fb2") ||
strings.Contains(nameLower, "epub") ||
strings.Contains(nameLower, "djvu")
}
// validateWithLLM отправляет автора и название книги на проверку через LLM.
@@ -559,7 +541,7 @@ func (uc *ProcessAudioBooksUseCase) validateWithLLM(ctx context.Context, author,
}
if uc.logger != nil {
uc.logger.LogWarning(fmt.Sprintf("LLM(OpenRouter): запрос на валидацию метаданных — автор=%q, название=%q", author, title))
uc.logger.LogInfo(fmt.Sprintf("LLM(OpenRouter): запрос на валидацию метаданных — автор=%q, название=%q", author, title))
}
userMessage := fmt.Sprintf("Автор: %s\nНазвание: %s", author, title)
@@ -591,7 +573,7 @@ func (uc *ProcessAudioBooksUseCase) validateWithLLM(ctx context.Context, author,
}
if uc.logger != nil {
uc.logger.LogWarning(fmt.Sprintf("LLM(OpenRouter): ответ получен, модель=%s, токены=%d", resp.Model, resp.Usage.TotalTokens))
uc.logger.LogInfo(fmt.Sprintf("LLM(OpenRouter): ответ получен, модель=%s, токены=%d", resp.Model, resp.Usage.TotalTokens))
}
content := resp.Choices[0].Message.Content
@@ -624,9 +606,9 @@ func (uc *ProcessAudioBooksUseCase) validateWithLLM(ctx context.Context, author,
}
if uc.logger != nil && (resultAuthor != author || resultTitle != title) {
uc.logger.LogWarning(fmt.Sprintf("LLM: исправлено — автор: %q → %q, название: %q → %q", author, resultAuthor, title, resultTitle))
uc.logger.LogInfo(fmt.Sprintf("LLM: исправлено — автор: %q → %q, название: %q → %q", author, resultAuthor, title, resultTitle))
} else if uc.logger != nil {
uc.logger.LogWarning("LLM(OpenRouter): изменений не требуется")
uc.logger.LogInfo("LLM(OpenRouter): изменений не требуется")
}
return resultAuthor, resultTitle
+182
View File
@@ -0,0 +1,182 @@
# Алгоритм конвейера
Обработка одной книги проходит через несколько последовательных шагов внутри функции `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()
```
+116
View File
@@ -0,0 +1,116 @@
# Архитектура
GenAudioBookInfo построен по принципам **Чистой архитектуры** (Clean Architecture / Hexagonal Architecture).
Бизнес-логика не зависит от внешних библиотек, баз данных или UI-фреймворков.
---
## Слои
```
┌──────────────────────────────────────────────────────────┐
│ cmd/genaudiobookinfo/main.go (Composition Root / DI) │
├──────────────────────────────────────────────────────────┤
│ presentation/ TUI, ConsoleLogger, Presenter │
├──────────────────────────────────────────────────────────┤
│ usecase/ Бизнес-логика. Нет зависимостей │
│ от infra, только интерфейсы │
├──────────────────────────────────────────────────────────┤
│ domain/ Сущности + интерфейсы (порты) │
├──────────────────────────────────────────────────────────┤
│ infrastructure/ Адаптеры: FS, HTTP, теги, YAML │
└──────────────────────────────────────────────────────────┘
```
Зависимости направлены **внутрь**: `main → usecase → domain ← infrastructure`.
---
## Структура папок
```
genaudiobookinfo/
├── cmd/
│ └── genaudiobookinfo/
│ └── main.go # Точка входа, DI, TUI запуск
├── internal/
│ ├── domain/
│ │ ├── audiobook.go # Агрегат AudioBookInfo, EnrichedBookInfo
│ │ ├── llm.go # Интерфейс LLMClient
│ │ ├── ports.go # Все порты: FolderLister, MetadataExtractor,
│ │ │ # TorrentSearcher, ResultWriter, CoverDownloader,
│ │ │ # ProcessLogger, ProcessResult
│ │ └── torrent.go # Сущности TorrentInfo, TorrentDetail
│ ├── usecase/
│ │ └── scan_audiobooks.go # Конвейер (Pipeline + Fan-Out/Fan-In)
│ ├── infrastructure/ # Адаптеры (реализации портов)
│ │ ├── folder_lister.go # FSFolderLister
│ │ ├── metadata_extractor.go # TagMetadataExtractor (dhowden/tag)
│ │ ├── torrapi_client.go # TorrAPIClient (HTTP)
│ │ ├── result_writer.go # FSResultWriter (создание папок, JSON, копирование)
│ │ ├── cover_downloader.go # HTTPCoverDownloader
│ │ ├── openrouter_client.go # OpenRouterClient (LLM через REST)
│ │ ├── audio_utils.go # Утилиты: длительность, формат, список файлов
│ │ ├── console_windows.go # SetConsoleUTF8 для Windows
│ │ └── console_other.go # Заглушка для non-Windows
│ ├── nameparser/
│ │ └── parser.go # Разбор "Автор - Название" из имени папки
│ └── presentation/
│ ├── tui_logger.go # TUILogger (Bubbletea, Dracula)
│ ├── console_logger.go # ConsoleLogger (fallback без TUI)
│ └── console_presenter.go # Финальная сводка в stdout
├── config.yaml
├── go.mod
├── go.sum
├── Makefile
└── README.md
```
---
## Интерфейсы (порты)
Определены в `internal/domain/ports.go`:
| Интерфейс | Назначение | Адаптер |
|---|---|---|
| `FolderLister` | Перечислить подпапки | `FSFolderLister` |
| `MetadataExtractor` | Извлечь теги из аудиофайла | `TagMetadataExtractor` |
| `TorrentSearcher` | Поиск + детали раздачи | `TorrAPIClient` |
| `ResultWriter` | Записать результат на диск | `FSResultWriter` |
| `CoverDownloader` | Скачать обложку | `HTTPCoverDownloader` |
| `LLMClient` | Нормализовать автора/название | `OpenRouterClient` |
| `ProcessLogger` | Отчитываться о прогрессе | `TUILogger` / `ConsoleLogger` |
---
## Паттерны
| Паттерн | Где применён |
|---|---|
| Pipeline | `ExecuteForFolders` → `processOneBook` → writer |
| Fan-Out / Fan-In | Worker Pool в `ExecuteForFolders` |
| Semaphore | `searchSem` — ограничение параллелизма к TorrAPI |
| Dependency Injection | `main.go` — Composition Root без фреймворка |
| Repository / Port-Adapter | Все `infrastructure/` адаптеры |
| Strategy | `ProcessLogger` — TUI или Console выбирается в `main.go` |
| Graceful Shutdown | `context.WithTimeout` + `signal.Notify` (SIGINT, SIGTERM) |
---
## TUI (Bubbletea)
`presentation/tui_logger.go` реализует полноэкранный интерфейс:
- **Верхняя панель**: текущая книга, статус, прогресс-бар `N / Total`, спиннер, последние 5 событий
- **Нижняя панель**: полный лог с цветами (Dracula palette)
Цветовая схема:
| Тип | Цвет |
|---|---|
| INFO | `#00FFFF` cyan |
| WARN | `#FFFF00` yellow |
| ERROR | `#FF5555` red |
| OK | `#50FA7B` green |
| DEBUG | `#6272A4` gray |
+123
View File
@@ -0,0 +1,123 @@
# Использование CLI
## Синтаксис
```
genaudiobookinfo [опции] [путь к каталогу с аудиокнигами]
```
Если путь не передан, используется `dir.in` из `config.yaml`.
---
## Флаги
| Флаг | Тип | По умолчанию | Описание |
|---|---|---|---|
| `-workers N` | int | `0` (из config) | Количество параллельных воркеров. Переопределяет `processing.workers`. |
| `-timeout T` | duration | `0` (из config) | Таймаут всей сессии. Формат: `5m`, `1h30m`. Переопределяет `processing.timeout`. |
| `-api URL` | string | из config | URL TorrAPI сервера. Переопределяет `torrapi.url`. |
| `-result DIR` | string | `<вход>/result` | Папка для результатов. Переопределяет `dir.out`. |
| `-version` | flag | — | Вывести версию и выйти. |
### Приоритет параметров
```
CLI-флаг > config.yaml > встроенные defaults
```
Флаги `-workers` и `-timeout` со значением `0` (умолчание) не перетирают конфиг.
---
## Примеры
### Минимальный запуск (всё из config.yaml)
```bash
./genaudiobookinfo
```
### Указать входной каталог явно
```bash
./genaudiobookinfo D:\Audiobooks
```
### Переопределить количество воркеров
```bash
./genaudiobookinfo -workers 6 D:\Audiobooks
```
### Другой сервер TorrAPI
```bash
./genaudiobookinfo -api http://192.168.1.10:9200 D:\Audiobooks
```
### Указать нестандартную папку результатов
```bash
./genaudiobookinfo -result E:\ProcessedBooks D:\Audiobooks
```
### Полный набор параметров
```bash
./genaudiobookinfo \
-workers 4 \
-timeout 30m \
-api http://localhost:9200 \
-result D:\Books\result \
D:\Books\Incoming
```
### Проверить версию
```bash
./genaudiobookinfo -version
# GenAudioBookInfo v2.0.0
```
---
## Переменные окружения
| Переменная | Описание |
|---|---|
| `OPENROUTER_API_KEY` | API ключ OpenRouter. Используется если `openrouter.api_key` в config.yaml пуст. |
```bash
export OPENROUTER_API_KEY=sk-or-v1-xxxx
./genaudiobookinfo D:\Audiobooks
```
---
## Управление в TUI
Во время обработки работает полноэкранный TUI:
| Клавиша | Действие |
|---|---|
| `q` | Мягкое завершение (graceful stop, ждёт текущий воркер) |
| `Ctrl+C` | Прерывание (cancel context) |
---
## Сигналы ОС
| Сигнал | Действие |
|---|---|
| `SIGINT` (Ctrl+C) | Отмена контекста → воркеры завершают текущую книгу |
| `SIGTERM` | То же |
---
## Код выхода
| Код | Значение |
|---|---|
| `0` | Успешное завершение |
| `1` | Ошибка запуска (нет входного каталога, ошибка сканирования) |
+130
View File
@@ -0,0 +1,130 @@
# Конфигурация (`.env`)
Все параметры хранятся в файле `.env` в корне проекта.
Копируется из `.env.example` при первой настройке. Файл `.env` не коммитится в Git.
Параметры командной строки (где применимо) **переопределяют** значения из `.env`.
---
## Пути
```env
DIR_IN=D:\Audiobooks
DIR_OUT=D:\Audiobooks\result
```
| Переменная | Тип | По умолчанию | Описание |
|---|---|---|---|
| `DIR_IN` | string | — | Корневой каталог для сканирования. Можно переопределить аргументом CLI. |
| `DIR_OUT` | string | `<DIR_IN>/result` | Куда складывать обработанные книги. Можно переопределить флагом `-result`. |
---
## TorrAPI сервер
```env
TORRAPI_URL=http://localhost:9200
```
| Переменная | Тип | По умолчанию | Описание |
|---|---|---|---|
| `TORRAPI_URL` | string | `http://localhost:9200` | URL TorrAPI-совместимого сервера. Переопределяется флагом `-api`. |
---
## Параметры конвейера
```env
PROCESSING_WORKERS=2
PROCESSING_TIMEOUT=5m
PROCESSING_SEARCH_RETRIES=3
PROCESSING_SEARCH_RETRY_DELAY=3s
PROCESSING_SEARCH_CONCURRENCY=2
```
| Переменная | Тип | По умолчанию | Описание |
|---|---|---|---|
| `PROCESSING_WORKERS` | int | `2` | Число параллельных горутин-воркеров (Fan-Out). Переопределяется `-workers`. |
| `PROCESSING_TIMEOUT` | duration | `5m` | Дедлайн для всей сессии обработки. Переопределяется `-timeout`. |
| `PROCESSING_SEARCH_RETRIES` | int | `3` | Сколько попыток найти книгу на трекерах перед перемещением в `ERROR/`. |
| `PROCESSING_SEARCH_RETRY_DELAY` | duration | `3s` | Задержка между повторными попытками поиска. |
| `PROCESSING_SEARCH_CONCURRENCY` | int | `2` | Ограничение числа одновременных HTTP-запросов к TorrAPI (семафор). |
### Приоритет параметров
```
CLI-флаг (-workers/-timeout) > .env > встроенные defaults
```
Флаги `-workers` и `-timeout` со значением `0` (умолчание) не перетирают значение из `.env`.
---
## OpenRouter LLM (опционально)
```env
OPENROUTER_API_KEY=sk-or-v1-your-key
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_TIMEOUT=120s
OPENROUTER_MODEL=openai/gpt-4o-mini
OPENROUTER_MAX_RETRIES=3
OPENROUTER_RETRY_BACKOFF=1s
OPENROUTER_RETRY_BACKOFF_MAX=8s
# OPENROUTER_PROMPT= # опционально, переопределяет встроенный промпт
```
| Переменная | Тип | По умолчанию | Описание |
|---|---|---|---|
| `OPENROUTER_API_KEY` | string | `""` | API ключ. Если пуст — LLM выключен. Можно передать через переменную окружения ОС. |
| `OPENROUTER_BASE_URL` | string | `https://openrouter.ai/api/v1` | Базовый URL API. |
| `OPENROUTER_TIMEOUT` | duration | `120s` | Таймаут одного запроса к API. |
| `OPENROUTER_MODEL` | string | `openai/gpt-3.5-turbo` | Идентификатор модели в формате `provider/model`. |
| `OPENROUTER_MAX_RETRIES` | int | `3` | Количество ретраев при ошибках API. |
| `OPENROUTER_RETRY_BACKOFF` | duration | `1s` | Начальная задержка между ретраями (экспоненциальный backoff). |
| `OPENROUTER_RETRY_BACKOFF_MAX` | duration | `8s` | Максимальная задержка backoff. |
| `OPENROUTER_PROMPT` | string | (встроенный) | Системный промпт. Если не задан — используется дефолтный из кода. |
### Ключ через переменную окружения ОС
`OPENROUTER_API_KEY` можно не писать в `.env` — достаточно задать в окружении ОС. Переменные ОС имеют приоритет над `.env`:
```bash
export OPENROUTER_API_KEY=sk-or-...
./genaudiobookinfo
```
---
## Форматы продолжительностей
Go-синтаксис: `300ms`, `1.5s`, `2m30s`, `1h`.
Примеры для `*_TIMEOUT`: `5m`, `10m`, `1h30m`.
---
## Полный пример `.env`
```env
# Пути
DIR_IN=D:\Audiobooks
DIR_OUT=D:\Audiobooks\result
# TorrAPI
TORRAPI_URL=http://localhost:9200
# Конвейер
PROCESSING_WORKERS=4
PROCESSING_TIMEOUT=15m
PROCESSING_SEARCH_RETRIES=3
PROCESSING_SEARCH_RETRY_DELAY=3s
PROCESSING_SEARCH_CONCURRENCY=2
# OpenRouter
OPENROUTER_API_KEY=sk-or-v1-xxxxxxxxxxxxxxxxxxxx
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_TIMEOUT=120s
OPENROUTER_MODEL=openai/gpt-4o-mini
OPENROUTER_MAX_RETRIES=3
OPENROUTER_RETRY_BACKOFF=1s
OPENROUTER_RETRY_BACKOFF_MAX=8s
```
+62
View File
@@ -0,0 +1,62 @@
# GenAudioBookInfo
> Автоматический каталогизатор аудиокниг с обогащением метаданных из торрент-трекеров и LLM.
## Что делает инструмент
1. **Сканирует** каталог с аудиокнигами — находит все подпапки с аудиофайлами (mp3, m4b, ogg, flac, opus, aac и др.).
2. **Извлекает метаданные** из аудиофайлов: автор, название, год, жанр, длительность через ID3/Vorbis теги.
3. **Ищет книгу на трекерах** через [TorrAPI](TorrAPI) по имени папки и/или тегам — получает расширенное описание.
4. **Опционально** уточняет автора и название через LLM ([OpenRouter](OpenRouter)).
5. **Создаёт структуру** `result/<Буква>/<Автор>/<Автор — Книга [Год]>/` с `metadata.json` и обложкой.
6. Папки, которые не удалось найти, уходят в `ERROR/`; дубликаты — в `DUPLICATE/`.
## Быстрый старт
```bash
# Сборка
go build -o genaudiobookinfo ./cmd/genaudiobookinfo
# Первичная настройка
cp .env.example .env
# Отредактировать .env: задать DIR_IN, TORRAPI_URL, OPENROUTER_API_KEY
# Запуск (входная папка из DIR_IN в .env)
./genaudiobookinfo
# Явное указание каталога
./genaudiobookinfo D:\Audiobooks
# С переопределением параметров
./genaudiobookinfo -workers 4 -timeout 10m D:\Audiobooks
```
Полная документация по флагам: [[CLI-Usage]].
## Структура вики
| Страница | Содержание |
|---|---|
| [[Installation]] | Требования, сборка из исходников |
| [[Configuration]] | Справочник `.env` (все переменные конфигурации) |
| [[CLI-Usage]] | Флаги командной строки, примеры |
| [[Architecture]] | Чистая архитектура, слои, паттерны |
| [[Algorithm]] | Шаги конвейера, ретрай, ERROR, DUPLICATE |
| [[Output-Structure]] | Структура `result/`, формат `metadata.json` |
| [[TorrAPI]] | Интеграция с торрент-трекерами |
| [[OpenRouter]] | LLM-интеграция для нормализации метаданных |
## Поддерживаемые форматы
`mp3` · `m4b` · `m4a` · `ogg` · `opus` · `flac` · `aac` · `wma` · `wav` · `aiff`
## Требования
- Go 1.22+
- [TorrAPI](https://github.com/yourok/TorrServer) (локальный или удалённый сервер)
- Ключ [OpenRouter](https://openrouter.ai/) — **опционально**, для нормализации автора/названия через LLM
## Ссылки
- Репозиторий: `https://github.dfv24.com/fofanov/genaudiobookinfo`
- Документация OpenRouter: [[OpenRouter]]
+89
View File
@@ -0,0 +1,89 @@
# Установка и сборка
## Требования
| Компонент | Версия | Обязательно |
|---|---|---|
| Go | 1.22+ | ✅ |
| TorrAPI-совместимый сервер | любая | ✅ |
| OpenRouter API ключ | — | ❌ (только для LLM) |
## Сборка из исходников
```bash
# Клонировать репозиторий
git clone https://github.dfv24.com/fofanov/genaudiobookinfo.git
cd genaudiobookinfo
# Загрузить зависимости
go mod tidy
# Сборка
go build -o genaudiobookinfo ./cmd/genaudiobookinfo
# Или через Makefile
make build
```
### Windows
```powershell
go build -o genaudiobookinfo.exe ./cmd/genaudiobookinfo
```
## Зависимости
Все зависимости управляются через Go Modules. Основные:
| Модуль | Назначение |
|---|---|
| `charmbracelet/bubbletea` | TUI-фреймворк (интерактивный вывод) |
| `charmbracelet/lipgloss` | Цветовое оформление терминала (Dracula scheme) |
| `charmbracelet/bubbles` | Компоненты TUI: прогресс, спиннер |
| `dhowden/tag` | Чтение ID3/Vorbis/MP4 тегов из аудиофайлов |
| `tcolgate/mp3` | Расчёт длительности MP3 с VBR |
| `schollz/progressbar/v3` | Прогресс-бар для ConsoleLogger |
| `gopkg.in/yaml.v3` | ~~Парсинг `config.yaml`~~ удалён |
## Первичная настройка
1. Скопировать шаблон конфигурации:
```bash
cp .env.example .env
```
2. Изменить пути в `.env`:
```env
DIR_IN=/path/to/audiobooks # входная папка
DIR_OUT=/path/to/result # папка результатов (опционально)
```
3. Настроить TorrAPI:
```env
TORRAPI_URL=http://localhost:9200 # адрес TorrServer
```
4. Запустить:
```bash
./genaudiobookinfo
```
## Проверка сборки
```bash
go vet ./...
go test ./...
./genaudiobookinfo -version
```
## Обновление
```bash
git pull
go mod tidy
make build
```
+398
View File
@@ -0,0 +1,398 @@
# Makefile — описание команд
В проекте используется `Makefile` как единая точка входа для сборки, тестирования, линтинга и управления релизами. Файл поддерживает **Windows (PowerShell)** и **Unix (sh)** — ветвление происходит автоматически через `ifeq ($(OS),Windows_NT)`.
Команда по умолчанию (`make` без аргументов) показывает справку.
---
## Переменные конфигурации
| Переменная | Значение по умолчанию | Назначение |
|---------------|-----------------------------------|----------------------------------------------------|
| `APP_NAME` | `genaudiobookinfo` | Имя исполняемого файла |
| `VERSION` | `2.0.0` | Версия, встраивается в бинарник через `-ldflags` |
| `BUILD_DIR` | `build` | Директория для артефактов сборки |
| `CMD_PATH` | `./cmd/genaudiobookinfo` | Путь к `main.go` |
| `GO` | `go` | Команда Go-компилятора |
| `GOFLAGS` | `-trimpath` | Флаги компилятора (убирает пути хоста из бинарника)|
| `LDFLAGS` | `-s -w -X main.version=$(VERSION)`| Линковщик: strip debug, strip DWARF, встроить версию |
| `OUTPUT_DIR` | `./result` | Директория с результатами работы программы |
| `GITEA_URL` | `https://github.dfv24.com` | Адрес Gitea-инстанции |
| `GITEA_REPO` | `fofanov/genaudiobookinfo` | Владелец/репозиторий в Gitea |
| `GITEA_TOKEN` | *(пусто)* | Токен API Gitea (нужен только для `ci-release`) |
Переменные можно переопределять при вызове:
```bash
make build VERSION=2.1.0
make ci-release VERSION=2.1.0 GITEA_TOKEN=abc123
```
---
## Основные команды
### `make` / `make help`
Вывод справки со списком всех доступных команд. Цель по умолчанию — не требует аргументов.
```bash
make
```
---
### `make all`
Последовательно выполняет `build` и `vet`. Удобен как первичная проверка после изменений.
```bash
make all
```
---
### `make build`
Компилирует бинарник для **текущей платформы** и помещает его в `build/`.
| ОС | Результирующий файл |
|---------|----------------------------------|
| Windows | `build/genaudiobookinfo.exe` |
| Linux | `build/genaudiobookinfo` |
| macOS | `build/genaudiobookinfo` |
Флаги компилятора: `-trimpath -ldflags "-s -w -X main.version=2.0.0"`.
```bash
make build
make build VERSION=2.1.0 # встроить другую версию
```
---
### `make build-windows-current`
Компилирует `genaudiobookinfo.exe` (Windows/amd64) **в корневой директории проекта** (не в `build/`). Удобно для быстрой локальной проверки на Windows без отдельной папки.
```bash
make build-windows-current
```
---
### `make run`
Запускает программу напрямую через `go run` без предварительной компиляции с параметрами по умолчанию: `-workers 8 -timeout 60m`. Директории `DIR_IN` и `DIR_OUT` берутся из `.env`.
```bash
make run
```
---
### `make run-custom ARGS="..."`
Запускает программу с произвольными CLI-флагами через переменную `ARGS`.
```bash
make run-custom ARGS="-workers 4 -timeout 30m"
make run-custom ARGS="-workers 2"
```
> Полный список CLI-флагов: [[CLI-Usage|CLI-Usage]].
---
## Тестирование
### `make test`
Запускает все unit-тесты с флагами `-v -race -count=1`.
- `-v` — podробный вывод
- `-race` — детектор гонок (data race detector)
- `-count=1` — отключает кэширование результатов тестов
```bash
make test
```
---
### `make test-cover`
Запускает тесты с измерением покрытия кода и генерирует HTML-отчёт `coverage.html`.
```bash
make test-cover
# откройте coverage.html в браузере для просмотра отчёта
```
Файлы:
- `coverage.out` — сырые данные покрытия
- `coverage.html` — визуальный отчёт (удаляется через `make clean`)
---
## Качество кода
### `make fmt`
Форматирует весь Go-код согласно стандарту `gofmt`. Эквивалент `go fmt ./...`.
```bash
make fmt
```
---
### `make vet`
Запускает статический анализ `go vet ./...`. Выявляет потенциальные ошибки: неправильный printf-формат, достижимые nil-dereference и т.п.
```bash
make vet
```
---
### `make lint`
Запускает `golangci-lint run ./...`. Если `golangci-lint` не установлен, выводит команду для установки.
```bash
make lint
# Установка линтера:
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
```
---
### `make deps`
Загружает все зависимости модуля (`go mod download`). Полезно для первоначальной настройки или подготовки к офлайн-сборке.
```bash
make deps
```
---
### `make tidy`
Запускает `go mod tidy` — убирает неиспользуемые зависимости и обновляет `go.sum`.
```bash
make tidy
```
---
## Очистка
### `make clean`
Удаляет артефакты сборки:
- папку `build/`
- файл `genaudiobookinfo.exe` в корне (от `build-windows-current`)
- файлы `coverage.out` и `coverage.html`
```bash
make clean
```
---
### `make clean-results`
Удаляет и пересоздаёт директорию результатов (`./result/`). Используйте с осторожностью — удаляет все обработанные аудиокниги из выходной папки.
```bash
make clean-results
```
> Директория `OUTPUT_DIR` задаётся переменной Makefile (`./result` по умолчанию), но реальный путь при работе программы берётся из `DIR_OUT` в `.env`.
---
### `make clean-all`
Выполняет `clean` + `clean-results` — полная очистка проекта.
```bash
make clean-all
```
---
## Кросс-компиляция
### `make build-all`
Компилирует бинарники для **всех 16 поддерживаемых платформ**. Сначала выполняет `clean`, затем `ensure-build-dir`, затем собирает каждую платформу последовательно. Все результаты помещаются в `build/`.
```bash
make build-all
make build-all VERSION=2.1.0
```
Время выполнения: 1–3 минуты в зависимости от машины.
---
### Платформы и выходные файлы
| Команда make | GOOS | GOARCH | Доп. флаг | Файл |
|-------------------------|---------|----------|---------------|---------------------------------------------|
| `build-linux-amd64` | linux | amd64 | — | `genaudiobookinfo-linux-amd64` |
| `build-linux-386` | linux | 386 | — | `genaudiobookinfo-linux-386` |
| `build-linux-arm64` | linux | arm64 | — | `genaudiobookinfo-linux-arm64` |
| `build-linux-arm7` | linux | arm | GOARM=7 | `genaudiobookinfo-linux-armv7` |
| `build-darwin-amd64` | darwin | amd64 | — | `genaudiobookinfo-darwin-amd64` |
| `build-darwin-arm64` | darwin | arm64 | — | `genaudiobookinfo-darwin-arm64` |
| `build-windows-amd64` | windows | amd64 | — | `genaudiobookinfo-windows-amd64.exe` |
| `build-windows-386` | windows | 386 | — | `genaudiobookinfo-windows-386.exe` |
| `build-windows-arm64` | windows | arm64 | — | `genaudiobookinfo-windows-arm64.exe` |
| `build-arm-mips` | linux | mips | GOMIPS=softfloat | `genaudiobookinfo-linux-mips` |
| `build-arm-mipsle` | linux | mipsle | GOMIPS=softfloat | `genaudiobookinfo-linux-mipsle` |
| `build-arm-riscv64` | linux | riscv64 | — | `genaudiobookinfo-linux-riscv64` |
| `build-freebsd-amd64` | freebsd | amd64 | — | `genaudiobookinfo-freebsd-amd64` |
| `build-freebsd-arm64` | freebsd | arm64 | — | `genaudiobookinfo-freebsd-arm64` |
| `build-openbsd-amd64` | openbsd | amd64 | — | `genaudiobookinfo-openbsd-amd64` |
| `build-netbsd-amd64` | netbsd | amd64 | — | `genaudiobookinfo-netbsd-amd64` |
Каждую платформу можно собирать независимо:
```bash
make build-linux # Linux: все 4 варианта
make build-darwin # macOS: Intel + Apple Silicon
make build-windows # Windows: amd64 + 386 + arm64
make build-arm # Embedded: MIPS + MIPSle + RISC-V
make build-freebsd # FreeBSD: amd64 + arm64
make build-openbsd # OpenBSD: amd64
make build-netbsd # NetBSD: amd64
```
---
## Локальный релиз
### `make release`
Выполняет `build-all`, затем копирует все скомпилированные бинарники из `build/` в `build/release/`.
```bash
make release
```
---
### `make checksum`
Выполняет `release`, затем генерирует файл `build/release/checksums-sha256.txt` с SHA256-хешами всех бинарников.
- **Windows**: использует `Get-FileHash` (PowerShell)
- **Unix**: использует `sha256sum`
```bash
make checksum
```
---
### `make release-local`
**Рекомендуемая команда для полного локального релиза.** Псевдоним для `checksum` — собирает все платформы, создаёт архивную папку и файл контрольных сумм.
```bash
make release-local
make release-local VERSION=2.1.0
```
Результат: папка `build/release/` с 16 бинарниками и `checksums-sha256.txt`.
---
## CI/CD через Gitea
Для автоматизации релизов используется пайплайн `.gitea/workflows/release.yml` (3 стадии: `quality → build → publish`). Подробнее: [[Architecture|Architecture]].
### `make ci-check`
Показывает текущее состояние рабочего дерева (`git status --short`) и последние 5 коммитов. Рекомендуется запускать **перед созданием тега релиза**, чтобы убедиться, что все изменения закоммичены.
```bash
make ci-check
```
---
### `make release-tag VERSION=X.Y.Z`
Создаёт аннотированный git-тег `vX.Y.Z` и пушит его в `origin`. Это **автоматически запускает CI/CD pipeline** в Gitea (триггер `push tags v*.*.*`).
```bash
make ci-check # 1. проверяем состояние
make release-tag VERSION=2.1.0 # 2. создаём тег → CI запускается
```
> **Внимание**: если `VERSION` не изменён (остался `2.0.0`), команда выведет предупреждение, но тег всё равно будет создан. Обязательно указывайте нужную версию явно.
После создания тега статус CI доступен по адресу:
`https://github.dfv24.com/fofanov/genaudiobookinfo/actions`
---
### `make release-tag-delete VERSION=X.Y.Z`
Удаляет тег `vX.Y.Z` локально и в `origin`. Используется для исправления ошибочно созданного тега.
```bash
make release-tag-delete VERSION=2.1.0
```
Затем можно выполнить коммит исправлений и повторно создать тег:
```bash
git add .
git commit -m "fix: исправление перед релизом"
make release-tag VERSION=2.1.0
```
---
### `make ci-release VERSION=X.Y.Z GITEA_TOKEN=<токен>`
Запускает CI/CD workflow **вручную через Gitea REST API** (endpoint `workflow_dispatch`) — без создания git-тега. Удобно для тестирования пайплайна или выпуска сборки на уже существующей ветке.
```bash
make ci-release VERSION=2.1.0 GITEA_TOKEN=abc123def456
```
- **`GITEA_TOKEN`** обязателен — команда завершится с ошибкой, если токен не задан.
- Токен получается в настройках Gitea: *Settings → Applications → Generate Token* (scope: `write:repository`).
- **Windows**: использует `Invoke-RestMethod` (PowerShell)
- **Unix**: использует `curl`
---
## Типичные рабочие сценарии
### Разработка (ежедневно)
```bash
make fmt # форматирование перед коммитом
make vet # статический анализ
make test # запуск тестов
make build # локальная сборка
```
### Выпуск новой версии через CI
```bash
make ci-check # проверить состояние
make release-tag VERSION=2.1.0 # создать тег → CI запустится автоматически
```
### Локальный релиз (без CI)
```bash
make release-local VERSION=2.1.0
# результат: build/release/ — 16 бинарников + checksums-sha256.txt
```
### Отладка пайплайна CI
```bash
make ci-release VERSION=2.1.0 GITEA_TOKEN=abc123 # ручной запуск без тега
make release-tag-delete VERSION=2.1.0 # откат тега при ошибке
```
---
## Совместимость с платформами
| Функция | Windows (PowerShell) | Linux/macOS (sh) |
|--------------------------|----------------------|------------------|
| Установка `GOOS/GOARCH` | `$$env:GOOS='...'` | `GOOS=... $(GO)` |
| Создание директорий | `New-Item -Force` | `mkdir -p` |
| Удаление файлов | `Remove-Item -Force` | `rm -rf` |
| SHA256 контрольные суммы | `Get-FileHash` | `sha256sum` |
| Запрос к Gitea API | `Invoke-RestMethod` | `curl` |
| Вывод сообщений | `$(info ...)` | `$(info ...)` |
> `$(info ...)` используется вместо `@echo "..."` для корректной работы в PowerShell, где двойные кавычки обрабатываются иначе.
+137
View File
@@ -0,0 +1,137 @@
# OpenRouter (LLM интеграция)
GenAudioBookInfo поддерживает опциональную нормализацию метаданных через LLM с помощью [OpenRouter](https://openrouter.ai/) — унифицированного API-шлюза к сотням AI-моделей.
---
## Зачем нужно LLM
Теги в аудиофайлах и имена папок часто содержат:
- Инициалы вместо полного имени: `Акунин Б.` → `Акунин Борис`
- Неправильный порядок: `Борис Акунин` → `Акунин Борис`
- Лишние символы, транслитерацию, опечатки
- Номера серий и части в названии
LLM нормализует автора в формат `Фамилия Имя` и очищает название книги.
---
## Настройка
### 1. Получить API ключ
Зарегистрируйтесь на [openrouter.ai](https://openrouter.ai/) и создайте API ключ в личном кабинете.
### 2. Добавить в `config.yaml`
```yaml
openrouter:
api_key: sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
model: openai/gpt-4o-mini
```
### Или через переменную окружения
```bash
export OPENROUTER_API_KEY=sk-or-v1-xxxx
./genaudiobookinfo
```
Переменная `OPENROUTER_API_KEY` проверяется если `api_key` в конфиге пуст.
---
## Настройки в `config.yaml`
```yaml
openrouter:
api_key: sk-or-v1-xxxx # API ключ (или env OPENROUTER_API_KEY)
base_url: https://openrouter.ai/api/v1
timeout: 30s # Таймаут HTTP запроса
model: openai/gpt-4o-mini # Идентификатор модели
max_retries: 3 # Ретраи при ошибках API
retry_backoff: 2s # Начальная задержка backoff
retry_backoff_max: 30s # Максимальная задержка backoff
prompt: | # Системный промпт
Ты эксперт по русскоязычным аудиокнигам...
```
---
## Промпт
Промпт определяет, как LLM должна обработать метаданные. Требования к промпту:
1. Отвечать **только JSON** без дополнительного текста.
2. Формат ответа: `{"author": "Фамилия Имя", "title": "Название"}`.
3. Автор в формате `Фамилия Имя` (не инициалы).
### Пример промпта
```yaml
prompt: |
Ты эксперт по русскоязычным аудиокнигам.
Тебе дадут сырые данные об аудиокниге (автор и название).
Твоя задача — нормализовать их:
1. Автор: формат "Фамилия Имя" (полное имя, без инициалов)
2. Название: убрать номера частей, лишние скобки, год
Верни ответ строго в JSON формате:
{"author": "Фамилия Имя", "title": "Чистое название"}
Только JSON, никакого лишнего текста.
```
---
## Выбор модели
OpenRouter предоставляет доступ к сотням моделей.
Формат: `provider/model-name`.
Рекомендуемые модели для нормализации текста:
| Модель | Стоимость | Качество |
|---|---|---|
| `openai/gpt-4o-mini` | низкая | хорошее |
| `openai/gpt-4o` | средняя | отличное |
| `anthropic/claude-3-haiku` | низкая | хорошее |
| `google/gemini-flash-1.5` | очень низкая | хорошее |
| `meta-llama/llama-3.1-8b-instruct:free` | бесплатно | среднее |
Полный список: [openrouter.ai/models](https://openrouter.ai/models)
---
## Поведение при ошибках
| Ситуация | Действие |
|---|---|
| API ключ не задан | LLM выключен, книга обрабатывается с исходными тегами |
| Ошибка HTTP | Ретрай до `max_retries` раз с backoff |
| Невалидный JSON в ответе | WARN в лог, используются исходные теги |
| Пустые поля в ответе | LLM результат игнорируется, используются исходные теги |
| Таймаут запроса | WARN в лог, используются исходные теги |
LLM-ошибки **не прерывают** обработку книги — конвейер продолжается с оригинальными данными.
---
## Архитектура интеграции
```
usecase/scan_audiobooks.go
│
▼ (если llmClient != nil)
domain.LLMClient.NormalizeMetadata(rawAuthor, rawTitle)
│
▼
infrastructure/openrouter_client.go
OpenRouterClient.NormalizeMetadata()
│
├── HTTP POST /chat/completions
│ Authorization: Bearer <api_key>
│ {"model": "...", "messages": [...]}
│
└── разбор JSON {"author": "...", "title": "..."}
```
`LLMClient` — интерфейс в `domain/llm.go`. Легко заменить на любой другой LLM провайдер.
+152
View File
@@ -0,0 +1,152 @@
# Структура результатов
После обработки все аудиокниги раскладываются по структурированным папкам.
Корневая папка результатов — `result/` (или `dir.out` из `config.yaml`, или флаг `-result`).
---
## Основная структура
```
result/
├── А/
│ └── Акунин Борис/
│ ├── Акунин Борис — Азазель [2003]/
│ │ ├── metadata.json
│ │ ├── cover.jpg
│ │ ├── 001.mp3
│ │ └── 002.mp3
│ └── Акунин Борис — Турецкий гамбит [2003]/
│ ├── metadata.json
│ └── ...
├── Д/
│ └── Достоевский Федор/
│ └── ...
├── ERROR/
│ ├── Неизвестная_книга_123/
│ │ ├── _error.txt
│ │ └── <аудиофайлы...>
│ └── ...
└── DUPLICATE/
└── А/
└── Акунин Борис/
├── Акунин Борис — Азазель [2003]/
│ ├── metadata.json
│ └── ...
└── Акунин Борис — Азазель [2003]_2/
└── ...
```
### Правила формирования имён
1. Первый уровень — первая буква фамилии автора (кириллица/латиница).
2. Второй уровень — `Фамилия Имя` автора.
3. Третий уровень — `Автор — Название [Год]/` (год добавляется только если известен).
Специальные символы (`/`, `\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`) в именах папок заменяются безопасными аналогами.
---
## `metadata.json`
Создаётся в каждой папке с книгой. Пример:
```json
{
"title": "Азазель",
"authors": ["Акунин Борис"],
"year": 2003,
"genre": "Детектив",
"comment": "Первый роман серии «Приключения Эраста Фандорина»...",
"duration": "8h23m",
"format": "mp3",
"files_count": 24,
"cover_found": true,
"source": {
"folder": "Акунин - Азазель",
"file": "001.mp3"
},
"torrent": {
"id": "123456",
"tracker": "rutracker",
"title": "Акунин Б. — Азазель (Б. Дьяченко) [2003, MP3, 96 kbps]",
"seeds": 42,
"size": "198 MB",
"url": "https://rutracker.org/forum/viewtopic.php?t=123456"
}
}
```
### Поля metadata.json
| Поле | Тип | Описание |
|---|---|---|
| `title` | string | Нормализованное название книги |
| `authors` | []string | Список авторов |
| `year` | int | Год издания (0 если неизвестен) |
| `genre` | string | Жанр |
| `comment` | string | Описание из тегов / трекера |
| `duration` | string | Длительность первого трека |
| `format` | string | Формат аудиофайлов |
| `files_count` | int | Кол-во аудиофайлов в папке |
| `cover_found` | bool | Найдена ли обложка |
| `source.folder` | string | Исходное имя папки |
| `source.file` | string | Файл, из которого читались теги |
| `torrent.*` | object | Данные с трекера (если найдено) |
---
## Папка `ERROR/`
Книги, которые не удалось найти ни на одном трекере после всех попыток.
```
result/ERROR/
└── Исходное_имя_папки/
├── _error.txt ← причина (не найдено / таймаут / API недоступен)
├── 001.mp3
└── 002.mp3
```
### Файл `_error.txt`
```
Ошибка обработки: aудиокнига не найдена на трекерах
Папка: Автор - Название
Попыток: 3
Последняя ошибка: no results found for query "Название"
Время: 2024-01-15 14:23:45
```
---
## Папка `DUPLICATE/`
Книга с таким же именем целевой папки уже существует в `result/`.
```
result/DUPLICATE/
└── А/
└── Акунин Борис/
├── Акунин Борис — Азазель [2003]/ ← первый дубликат
├── Акунин Борис — Азазель [2003]_2/ ← второй
└── Акунин Борис — Азазель [2003]_3/ ← третий
```
Суффиксы `_2`, `_3`, ... добавляются итеративно до первого свободного имени.
---
## Обложка (`cover.jpg`)
Загружается из URL обложки в данных раздачи с трекера.
Если URL недоступен или скачивание завершилось ошибкой — книга сохраняется без `cover.jpg`, в лог пишется `WARN`.
---
## Аудиофайлы
Аудиофайлы **перемещаются** (не копируются) из исходной папки в папку результата.
Исходная папка после успешного переноса становится пустой (или удаляется, в зависимости от ОС).
Поддерживаемые расширения: `mp3`, `m4b`, `m4a`, `ogg`, `opus`, `flac`, `aac`, `wma`, `wav`, `aiff`.
+147
View File
@@ -0,0 +1,147 @@
# TorrAPI (Поиск на трекерах)
GenAudioBookInfo использует [TorrServer](https://github.com/YouROK/TorrServer) / TorrAPI-совместимый сервер для поиска аудиокниг на торрент-трекерах и получения метаданных раздач.
---
## Что такое TorrAPI
TorrServer — локальный сервис, который предоставляет JSON API для поиска по популярным торрент-трекерам:
- **Rutracker** — крупнейший русскоязычный трекер
- **Rutor** — популярный трекер
- **Kinozal** — кино и аудиокниги
- И другие трекеры в зависимости от конфигурации TorrServer
---
## Установка TorrServer
TorrServer распространяется отдельно. Скачать: [github.com/YouROK/TorrServer/releases](https://github.com/YouROK/TorrServer/releases)
```bash
# Linux/macOS
./TorrServer -port 9200
# Windows
TorrServer.exe -port 9200
```
После запуска TorrServer доступен на `http://localhost:9200`.
---
## Настройка в `config.yaml`
```yaml
torrapi:
url: http://localhost:9200 # Адрес TorrAPI сервера
```
Можно переопределить флагом:
```bash
./genaudiobookinfo -api http://192.168.1.10:9200 D:\Audiobooks
```
---
## Как выполняется поиск
### Алгоритм поиска одной книги
```
1. Попытаться найти по title (названию из тегов / имени папки)
2. Если результатов нет и title ≠ author:
попытаться найти по author (автору)
3. Если найдено: взять детали лучшего результата
4. Если не найдено: следующая попытка (до search_retries раз)
5. Если все попытки исчерпаны: переместить в ERROR/
```
### Критерий выбора лучшего результата
Трекеры ранжируются в порядке приоритета:
1. **Rutracker** (наиболее полные метаданные)
2. **Rutor**
3. **Kinozal**
4. Остальные (по количеству сидов)
Из результатов одного трекера выбирается раздача с наибольшим числом сидов.
---
## Ограничение параллелизма
Параметр `search_concurrency` (по умолчанию `2`) ограничивает число одновременных HTTP-запросов к TorrAPI через семафор. Это предотвращает перегрузку TorrServer при большом количестве воркеров.
```yaml
processing:
workers: 6 # воркеров до 6
search_concurrency: 2 # но к TorrAPI ходят только 2 одновременно
```
---
## Ретраи поиска
Если поиск вернул пустой результат или произошла ошибка сети:
```yaml
processing:
search_retries: 3 # попыток
search_retry_delay: 3s # пауза между попытками
```
Это полезно если TorrServer временно перегружен или трекер недоступен.
---
## Получаемые данные
После успешного поиска `GetDetail()` возвращает:
| Поле | Описание |
|---|---|
| `Title` | Полное название раздачи (трекерное) |
| `Comment` | Описание раздачи (аннотация книги) |
| `Year` | Год издания (если указан на трекере) |
| `Genre` | Жанр |
| `Authors` | Авторы в трекерном формате |
| `CoverURL` | URL обложки для скачивания |
| `Seeds` | Количество сидов |
| `Size` | Размер раздачи |
| `TrackerURL` | Ссылка на страницу раздачи |
Эти данные записываются в `metadata.json` в секцию `torrent.*`.
---
## Диагностика
### TorrServer недоступен
```
WARN TorrAPI недоступен: dial tcp 127.0.0.1:9200: connection refused
```
Решение: убедиться, что TorrServer запущен на указанном порту.
### Ничего не найдено
```
WARN [Книга] не найдена на трекерах (попытка 1/3)
...
ERROR [Книга] перемещена в ERROR/ после 3 попыток
```
Возможные причины:
- Нестандартное имя папки (числа, латиница вместо кириллицы)
- Книга действительно отсутствует на трекерах
- Слишком короткое/общее название
### Проверка вручную
```bash
curl "http://localhost:9200/api/v1/search?query=Акунин+Азазель" | jq .
```
+27
View File
@@ -0,0 +1,27 @@
## GenAudioBookInfo
- [[Home]]
- [[Installation]]
- [[CLI-Usage|Использование CLI]]
- [[Configuration|Конфигурация]]
---
### Устройство
- [[Architecture|Архитектура]]
- [[Algorithm|Алгоритм конвейера]]
- [[Output-Structure|Структура результатов]]
---
### Разработка и сборка
- [[Makefile|Makefile (make-команды)]]
---
### Интеграции
- [[TorrAPI|TorrAPI (трекеры)]]
- [[OpenRouter|OpenRouter (LLM)]]