Files
GenAudioBookInfo/internal/infrastructure/torrapi_client.go
Dmitriy Fofanov 41fb62f62e Добавлены страницы вики для GenAudioBookInfo: Home, Installation, Makefile, OpenRouter, Output Structure, TorrAPI и Sidebar.
Создана структура документации, описывающая функциональность, установку, использование CLI, архитектуру и интеграции с TorrAPI и OpenRouter.
Добавлены примеры конфигурации и метаданных, а также описание структуры выходных данных.
2026-02-23 13:19:39 +03:00

298 lines
8.7 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) {
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
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 {
shift := attempt
if shift > 30 {
shift = 30
}
delay := c.backoffBase << shift
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
}
}