Files
GenAudioBookInfo/internal/infrastructure/torrapi_client.go
Dmitriy Fofanov 402ce7f4f1 Функция: реализованы консольный логгер и презентер для обработки аудиокниг
- Добавлен ConsoleLogger для подробного логирования этапов обработки аудиокниг в консоли.

- Введен ConsolePresenter для форматированного вывода результатов сканирования в консоль.

- Создан ProcessAudioBooksUseCase для обработки полного конвейера обработки аудиокниг, включая сканирование папок, извлечение метаданных, поиск торрентов и запись результатов.

- Реализована проверка LLM для улучшения метаданных.

- Добавлена ​​обработка ошибок и логирование на всех этапах обработки.
2026-02-20 00:35:43 +03:00

289 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package infrastructure реализует порт TorrentSearcher —
// HTTP-клиент для TorrAPI (поиск по трекерам).
package infrastructure
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/fofanov/genaudiobookinfo/internal/domain"
)
// TorrAPIClient реализует domain.TorrentSearcher.
type TorrAPIClient struct {
baseURL string
httpClient *http.Client
maxRetries int
backoffBase time.Duration
backoffMax time.Duration
}
// NewTorrAPIClient создаёт клиент TorrAPI.
func NewTorrAPIClient(baseURL string) *TorrAPIClient {
return &TorrAPIClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
maxRetries: 3,
backoffBase: 1 * time.Second,
backoffMax: 8 * time.Second,
}
}
// SearchByTitle выполняет поиск по названию: GET /api/search/title/all?query=...&page=all&category=0&year=...&format=0
// Возвращает карту: TrackerName → []TorrentSearchResult (только трекеры с результатами).
func (c *TorrAPIClient) SearchByTitle(ctx context.Context, author, album string, year int) (map[domain.TrackerName][]domain.TorrentSearchResult, error) {
query := fmt.Sprintf("%s - %s", author, album)
yearStr := ""
if year > 0 {
yearStr = fmt.Sprintf("%d", year)
}
return c.searchByRaw(ctx, query, yearStr)
}
// SearchByQuery выполняет поиск по сырой строке query без дополнительной нормализации.
func (c *TorrAPIClient) SearchByQuery(ctx context.Context, query string) (map[domain.TrackerName][]domain.TorrentSearchResult, error) {
return c.searchByRaw(ctx, query, "")
}
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),
)
var body []byte
var lastErr error
for attempt := 0; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("ошибка создания запроса: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("ошибка запроса к TorrAPI (%s): %w", reqURL, err)
if c.shouldRetryTorrAPI(attempt, err, 0) {
if sleepErr := c.sleepWithCtx(ctx, c.nextTorrAPIBackoff(attempt)); sleepErr != nil {
return nil, lastErr
}
continue
}
return nil, lastErr
}
statusCode := resp.StatusCode
body, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("ошибка чтения ответа TorrAPI: %w", err)
if c.shouldRetryTorrAPI(attempt, err, statusCode) {
if sleepErr := c.sleepWithCtx(ctx, c.nextTorrAPIBackoff(attempt)); sleepErr != nil {
return nil, lastErr
}
continue
}
return nil, lastErr
}
if statusCode == http.StatusTooManyRequests || statusCode >= 500 {
lastErr = fmt.Errorf("TorrAPI вернул статус %d: %s", statusCode, string(body))
if c.shouldRetryTorrAPI(attempt, nil, statusCode) {
if sleepErr := c.sleepWithCtx(ctx, c.nextTorrAPIBackoff(attempt)); sleepErr != nil {
return nil, lastErr
}
continue
}
return nil, lastErr
}
if statusCode != http.StatusOK {
return nil, fmt.Errorf("TorrAPI вернул статус %d: %s", statusCode, string(body))
}
break
}
// Парсим как map[string]json.RawMessage, т.к. ответ может содержать
// как массив результатов, так и объект {"Result": "No matches..."}
var rawMap map[string]json.RawMessage
if err := json.Unmarshal(body, &rawMap); err != nil {
return nil, fmt.Errorf("ошибка разбора JSON от TorrAPI: %w", err)
}
results := make(map[domain.TrackerName][]domain.TorrentSearchResult)
for _, tracker := range domain.TrackerPriority {
raw, ok := rawMap[string(tracker)]
if !ok {
continue
}
// Пробуем распарсить как массив результатов
var items []domain.TorrentSearchResult
if err := json.Unmarshal(raw, &items); err == nil && len(items) > 0 {
results[tracker] = items
continue
}
// Пробуем распарсить как объект с Result (нет совпадений) — пропускаем
var noResult domain.TorrentNoResult
if err := json.Unmarshal(raw, &noResult); err == nil && noResult.Result != "" {
continue
}
}
return results, nil
}
// GetDetailByID выполняет запрос деталей: GET /api/search/id/{tracker}?query={id}
func (c *TorrAPIClient) GetDetailByID(ctx context.Context, tracker domain.TrackerName, id string) (*domain.TorrentDetail, error) {
apiPath, ok := domain.TrackerAPIPath[tracker]
if !ok {
return nil, fmt.Errorf("неизвестный трекер: %s", tracker)
}
reqURL := fmt.Sprintf("%s/api/search/id/%s?query=%s",
c.baseURL,
apiPath,
url.QueryEscape(id),
)
var body []byte
var lastErr error
for attempt := 0; ; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("ошибка создания запроса деталей: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("ошибка запроса деталей к TorrAPI (%s): %w", reqURL, err)
if c.shouldRetryTorrAPI(attempt, err, 0) {
if sleepErr := c.sleepWithCtx(ctx, c.nextTorrAPIBackoff(attempt)); sleepErr != nil {
return nil, lastErr
}
continue
}
return nil, lastErr
}
statusCode := resp.StatusCode
body, err = io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("ошибка чтения ответа деталей: %w", err)
if c.shouldRetryTorrAPI(attempt, err, statusCode) {
if sleepErr := c.sleepWithCtx(ctx, c.nextTorrAPIBackoff(attempt)); sleepErr != nil {
return nil, lastErr
}
continue
}
return nil, lastErr
}
if statusCode == http.StatusTooManyRequests || statusCode >= 500 {
lastErr = fmt.Errorf("TorrAPI деталей вернул статус %d: %s", statusCode, string(body))
if c.shouldRetryTorrAPI(attempt, nil, statusCode) {
if sleepErr := c.sleepWithCtx(ctx, c.nextTorrAPIBackoff(attempt)); sleepErr != nil {
return nil, lastErr
}
continue
}
return nil, lastErr
}
if statusCode != http.StatusOK {
return nil, fmt.Errorf("TorrAPI деталей вернул статус %d: %s", statusCode, string(body))
}
break
}
// Ответ — массив, берём первый элемент
var details []domain.TorrentDetail
if err := json.Unmarshal(body, &details); err != nil {
return nil, fmt.Errorf("ошибка разбора JSON деталей: %w", err)
}
if len(details) == 0 {
return nil, fmt.Errorf("пустой ответ деталей для %s id=%s", tracker, id)
}
return &details[0], nil
}
// shouldRetryTorrAPI определяет, стоит ли повторить запрос к TorrAPI.
func (c *TorrAPIClient) shouldRetryTorrAPI(attempt int, err error, statusCode int) bool {
if attempt >= c.maxRetries {
return false
}
if statusCode == http.StatusTooManyRequests || statusCode >= 500 {
return true
}
if err == nil {
return false
}
// Не повторяем при отмене контекста
if errors.Is(err, context.Canceled) {
return false
}
// Повторяем при таймаутах и сетевых ошибках
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
errText := strings.ToLower(err.Error())
return strings.Contains(errText, "connection refused") ||
strings.Contains(errText, "connection reset") ||
strings.Contains(errText, "timeout") ||
strings.Contains(errText, "eof")
}
// nextTorrAPIBackoff вычисляет задержку перед повтором (exponential backoff).
func (c *TorrAPIClient) nextTorrAPIBackoff(attempt int) time.Duration {
delay := c.backoffBase << attempt
if delay <= 0 {
delay = c.backoffBase
}
if c.backoffMax > 0 && delay > c.backoffMax {
delay = c.backoffMax
}
return delay
}
// sleepWithCtx ждёт заданное время с учётом контекста.
func (c *TorrAPIClient) sleepWithCtx(ctx context.Context, wait time.Duration) error {
if wait <= 0 {
return nil
}
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}