// 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 } }