package indexer import ( "context" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" ) var ( ErrAuthFailed = errors.New("authentication failed") ErrSearchFailed = errors.New("search failed") ErrRateLimited = errors.New("rate limited") ErrUnavailable = errors.New("indexer unavailable") ErrParseError = errors.New("parse error") ) type TorznabIndexer struct { name string baseURL *url.URL apiKey string categories []uint32 client *http.Client } func NewTorznabIndexer(name, baseURL, apiKey string) (*TorznabIndexer, error) { u, err := url.Parse(baseURL) if err != nil { return nil, fmt.Errorf("invalid URL: %w", err) } return &TorznabIndexer{ name: name, baseURL: u, apiKey: apiKey, categories: []uint32{3000, 3010, 3040}, client: &http.Client{}, }, nil } func (i *TorznabIndexer) WithCategories(cats []uint32) *TorznabIndexer { i.categories = cats return i } func (i *TorznabIndexer) Name() string { return i.name } func (i *TorznabIndexer) buildSearchURL(criteria *MusicSearchCriteria) string { u := *i.baseURL q := u.Query() q.Set("t", "music") q.Set("apikey", i.apiKey) q.Set("extended", "1") var cats []string for _, c := range i.categories { cats = append(cats, strconv.FormatUint(uint64(c), 10)) } q.Set("cat", strings.Join(cats, ",")) var qParts []string qParts = append(qParts, criteria.CleanArtist()) if album := criteria.CleanAlbum(); album != nil { qParts = append(qParts, *album) } if criteria.Year != nil { qParts = append(qParts, strconv.FormatUint(uint64(*criteria.Year), 10)) } q.Set("q", strings.Join(qParts, " ")) q.Set("limit", strconv.Itoa(criteria.Limit)) q.Set("offset", strconv.Itoa(criteria.Offset)) u.RawQuery = q.Encode() return u.String() } func (i *TorznabIndexer) Search(ctx context.Context, criteria *MusicSearchCriteria) ([]SearchResult, error) { searchURL := i.buildSearchURL(criteria) req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { return nil, err } resp, err := i.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { retryAfter := 60 if ra := resp.Header.Get("Retry-After"); ra != "" { if v, err := strconv.Atoi(ra); err == nil { retryAfter = v } } return nil, fmt.Errorf("%w: retry after %d seconds", ErrRateLimited, retryAfter) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("%w: HTTP %d", ErrUnavailable, resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return i.parseResponse(body) } func (i *TorznabIndexer) TestConnection(ctx context.Context) error { u := *i.baseURL q := u.Query() q.Set("t", "caps") q.Set("apikey", i.apiKey) u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) if err != nil { return err } resp, err := i.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("%w: HTTP %d", ErrUnavailable, resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return err } xmlStr := string(body) if strings.Contains(xmlStr, "