Добавлены страницы вики для GenAudioBookInfo: Home, Installation, Makefile, OpenRouter, Output Structure, TorrAPI и Sidebar.
Создана структура документации, описывающая функциональность, установку, использование CLI, архитектуру и интеграции с TorrAPI и OpenRouter. Добавлены примеры конфигурации и метаданных, а также описание структуры выходных данных.
This commit is contained in:
@@ -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": "Название", ...}
|
||||
@@ -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
|
||||
@@ -8,3 +8,6 @@
|
||||
vendor/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Конфигурация с секретами — не коммитить
|
||||
.env
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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` | Ошибка запуска (нет входного каталога, ошибка сканирования) |
|
||||
@@ -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
|
||||
```
|
||||
@@ -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]]
|
||||
@@ -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
|
||||
```
|
||||
@@ -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, где двойные кавычки обрабатываются иначе.
|
||||
@@ -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 провайдер.
|
||||
@@ -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
@@ -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 .
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
## GenAudioBookInfo
|
||||
|
||||
- [[Home]]
|
||||
- [[Installation]]
|
||||
- [[CLI-Usage|Использование CLI]]
|
||||
- [[Configuration|Конфигурация]]
|
||||
|
||||
---
|
||||
|
||||
### Устройство
|
||||
|
||||
- [[Architecture|Архитектура]]
|
||||
- [[Algorithm|Алгоритм конвейера]]
|
||||
- [[Output-Structure|Структура результатов]]
|
||||
|
||||
---
|
||||
|
||||
### Разработка и сборка
|
||||
|
||||
- [[Makefile|Makefile (make-команды)]]
|
||||
|
||||
---
|
||||
|
||||
### Интеграции
|
||||
|
||||
- [[TorrAPI|TorrAPI (трекеры)]]
|
||||
- [[OpenRouter|OpenRouter (LLM)]]
|
||||
Reference in New Issue
Block a user