41fb033d30
- Replace Axum with Chi router - Replace sqlx with pgx for PostgreSQL - Replace tonic/prost with grpc-go - Replace tracing with zerolog - Update flake.nix for Go build with protoc generation - Preserve all existing endpoints and functionality Stack: Chi, pgx, grpc-go, zerolog, yaml.v3
290 lines
6.2 KiB
Go
290 lines
6.2 KiB
Go
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, "<error") && strings.Contains(xmlStr, `code="1`) {
|
|
return ErrAuthFailed
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type rssResponse struct {
|
|
Channel struct {
|
|
Items []rssItem `xml:"item"`
|
|
} `xml:"channel"`
|
|
Error *rssError `xml:"error"`
|
|
}
|
|
|
|
type rssError struct {
|
|
Code string `xml:"code,attr"`
|
|
Description string `xml:"description,attr"`
|
|
}
|
|
|
|
type rssItem struct {
|
|
GUID string `xml:"guid"`
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
Comments string `xml:"comments"`
|
|
PubDate string `xml:"pubDate"`
|
|
Enclosure enclosure `xml:"enclosure"`
|
|
Attrs []attr `xml:"attr"`
|
|
}
|
|
|
|
type enclosure struct {
|
|
URL string `xml:"url,attr"`
|
|
Length string `xml:"length,attr"`
|
|
}
|
|
|
|
type attr struct {
|
|
Name string `xml:"name,attr"`
|
|
Value string `xml:"value,attr"`
|
|
}
|
|
|
|
func (i *TorznabIndexer) parseResponse(data []byte) ([]SearchResult, error) {
|
|
var rss rssResponse
|
|
if err := xml.Unmarshal(data, &rss); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrParseError, err)
|
|
}
|
|
|
|
if rss.Error != nil {
|
|
if strings.HasPrefix(rss.Error.Code, "1") {
|
|
return nil, ErrAuthFailed
|
|
}
|
|
return nil, fmt.Errorf("%w: %s", ErrSearchFailed, rss.Error.Description)
|
|
}
|
|
|
|
var results []SearchResult
|
|
for _, item := range rss.Channel.Items {
|
|
result := i.parseItem(item)
|
|
results = append(results, result)
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (i *TorznabIndexer) parseItem(item rssItem) SearchResult {
|
|
attrs := make(map[string]string)
|
|
var categories []uint32
|
|
|
|
for _, a := range item.Attrs {
|
|
if a.Name == "category" {
|
|
if v, err := strconv.ParseUint(a.Value, 10, 32); err == nil {
|
|
categories = append(categories, uint32(v))
|
|
}
|
|
} else {
|
|
attrs[a.Name] = a.Value
|
|
}
|
|
}
|
|
|
|
size := uint64(0)
|
|
if s, ok := attrs["size"]; ok {
|
|
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
|
|
size = v
|
|
}
|
|
} else if item.Enclosure.Length != "" {
|
|
if v, err := strconv.ParseUint(item.Enclosure.Length, 10, 64); err == nil {
|
|
size = v
|
|
}
|
|
}
|
|
|
|
result := SearchResult{
|
|
GUID: item.GUID,
|
|
Title: item.Title,
|
|
DownloadURL: item.Link,
|
|
Size: size,
|
|
Indexer: i.name,
|
|
Categories: categories,
|
|
}
|
|
|
|
if item.Comments != "" {
|
|
result.InfoURL = &item.Comments
|
|
}
|
|
if item.PubDate != "" {
|
|
result.PublishDate = &item.PubDate
|
|
}
|
|
if v, ok := attrs["artist"]; ok {
|
|
result.Artist = &v
|
|
}
|
|
if v, ok := attrs["album"]; ok {
|
|
result.Album = &v
|
|
}
|
|
if v, ok := attrs["year"]; ok {
|
|
if y, err := strconv.ParseUint(v, 10, 32); err == nil {
|
|
y32 := uint32(y)
|
|
result.Year = &y32
|
|
}
|
|
}
|
|
if v, ok := attrs["label"]; ok {
|
|
result.Label = &v
|
|
}
|
|
if v, ok := attrs["seeders"]; ok {
|
|
if s, err := strconv.Atoi(v); err == nil {
|
|
result.Seeders = &s
|
|
}
|
|
}
|
|
if v, ok := attrs["leechers"]; ok {
|
|
if l, err := strconv.Atoi(v); err == nil {
|
|
result.Leechers = &l
|
|
}
|
|
}
|
|
if v, ok := attrs["grabs"]; ok {
|
|
if g, err := strconv.Atoi(v); err == nil {
|
|
result.Grabs = &g
|
|
}
|
|
}
|
|
if v, ok := attrs["infohash"]; ok {
|
|
result.Infohash = &v
|
|
}
|
|
if v, ok := attrs["magneturl"]; ok {
|
|
result.MagnetURL = &v
|
|
}
|
|
|
|
return result
|
|
}
|