Создана структура документации, описывающая функциональность, установку, использование CLI, архитектуру и интеграции с TorrAPI и OpenRouter. Добавлены примеры конфигурации и метаданных, а также описание структуры выходных данных.
298 lines
8.7 KiB
Go
298 lines
8.7 KiB
Go
// 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
|
||
}
|
||
}
|