package indexer import ( "context" "encoding/xml" "fmt" "net/http" "net/url" "strconv" "strings" "time" ) // DefaultTimeout is what an unset Timeout means. Longer than a metadata // call: a feed may itself fan out to several trackers before answering. const DefaultTimeout = 30 * time.Second // TorznabConfig is one Torznab feed. type TorznabConfig struct { // Code is the acquisition_services row this client was built from, // stamped onto every Result. Code string // FeedURL is the feed's whole address up to and including /api, as // Jackett or a Prowlarr indexer shows it — e.g. // http://prowlarr:9696/12/api. Query parameters are appended to it. // One field rather than Fabled Librarian's base + path + indexer id: a // pasted URL is more reliable than Minstrel being right about a third // party's routes. FeedURL string // APIKey is optional: Torznab makes it so. A feed that needs one says so // with Newznab error 100, which is ErrAuth. APIKey string Timeout time.Duration } // Torznab searches one Torznab feed. type Torznab struct { Config TorznabConfig // HTTP is injectable for tests. Nil takes http.DefaultClient. HTTP *http.Client } // Search asks the feed for q. `t=search` is free text, which every indexer // supports; `t=music` takes artist and album separately, and a release name // does not reliably say which of its parts is which. func (c *Torznab) Search(ctx context.Context, q Query) ([]Result, error) { params := url.Values{"t": {"search"}, "q": {q.Text}} for _, cat := range q.Categories { params.Add("cat", strconv.Itoa(cat)) } body, err := c.get(ctx, c.endpoint(params)) if err != nil { return nil, err } out, err := parseFeed(body) if err != nil { return nil, err } for i := range out { out[i].Source = c.Config.Code } return out, nil } // Download fetches a result's .torrent bytes through the feed, which holds // the tracker's credentials. A result with a magnet needs none of this. func (c *Torznab) Download(ctx context.Context, r Result) ([]byte, error) { if r.DownloadURL == "" { return nil, fmt.Errorf("%w: %q has no download URL", ErrUnreachable, r.Title) } body, err := c.get(ctx, r.DownloadURL) if err != nil { return nil, err } return torrentBody(r.Title, body) } // torrentBody checks a fetched .torrent. A torrent is a bencoded dictionary // and starts with 'd'; an expired session gets an HTML login page with HTTP // 200, which the download client would otherwise reject as an unsupported // file from a component that did nothing wrong. func torrentBody(title string, body []byte) ([]byte, error) { if err := MagnetBody(body); err != nil { return nil, err } if len(body) == 0 || body[0] != 'd' { return nil, fmt.Errorf("%w: %q returned %d bytes that are not a torrent", ErrUnreachable, title, len(body)) } return body, nil } // TorrentBody is torrentBody for the other search clients' fetches. func TorrentBody(title string, body []byte) ([]byte, error) { return torrentBody(title, body) } func (c *Torznab) endpoint(params url.Values) string { if c.Config.APIKey != "" { params.Set("apikey", c.Config.APIKey) } feed := strings.TrimSpace(c.Config.FeedURL) sep := "?" if strings.Contains(feed, "?") { sep = "&" } return feed + sep + params.Encode() } func (c *Torznab) get(ctx context.Context, endpoint string) ([]byte, error) { if strings.TrimSpace(c.Config.FeedURL) == "" { return nil, ErrUnconfigured } timeout := c.Config.Timeout if timeout <= 0 { timeout = DefaultTimeout } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, fmt.Errorf("%w: build request: %v", ErrUnreachable, err) } req.Header.Set("User-Agent", UserAgent) resp, body, err := Send(c.HTTP, req) if err != nil { return nil, err } if err := StatusError(resp, "the indexer"); err != nil { return nil, err } // Newznab reports errors as HTTP 200 with an document, so a // client checking only the status reads a refused key as an empty result // set: search quietly stops finding things. if err := errorDocument(body); err != nil { return nil, err } return body, nil } // errorDocument reads an answer. Newznab's 1xx // codes (100 wrong key, 101 suspended, 102 insufficient privileges) are all // about who is asking, so they are ErrAuth. func errorDocument(body []byte) error { head := body if len(head) > 512 { head = head[:512] } if !strings.Contains(string(head), " // and repeats it in , either populated by version. for _, candidate := range []string{it.Link, it.Enclosure.URL} { if strings.HasPrefix(candidate, "magnet:") && r.Magnet == "" { r.Magnet = candidate } else if candidate != "" && r.DownloadURL == "" { r.DownloadURL = candidate } } // The infohash from the explicit attr, then the magnet, then the GUID // only when it is shaped like a hash: Prowlarr puts a URL there as // often as a hash. r.InfoHash = NormalizeHash(it.attr("infohash")) if r.InfoHash == "" { r.InfoHash = MagnetHash(r.Magnet) } if r.InfoHash == "" && IsInfoHash(it.GUID) { r.InfoHash = NormalizeHash(it.GUID) } if t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)); err == nil { r.Published = t } else if t, err := time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)); err == nil { r.Published = t } out = append(out, r) } return out, nil } func atoi(s string) int { n, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil { return 0 } return n }