// Package prowlarr searches through Prowlarr's native API (M502 #5518). // Ported from Fabled Librarian's internal/prowlarr. // // Native rather than Torznab because Prowlarr has no aggregate Torznab feed: // `/api/v1/indexer/all/newznab` 404s exactly like an indexer id that does not // exist (measured by Fabled Librarian against a real Prowlarr, its #4304). // `/api/v1/search` is the one place Prowlarr asks every indexer at once. package prowlarr import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "git.fabledsword.com/bvandeusen/minstrel/internal/indexer" ) // SearchPath is Prowlarr's aggregate search. const SearchPath = "/api/v1/search" // DefaultTimeout is longer than a Torznab feed's: Prowlarr waits on every // indexer it has before answering. const DefaultTimeout = 60 * time.Second // Config is one Prowlarr. type Config struct { // Code is the acquisition_services row, stamped onto every Result. Code string BaseURL string // APIKey is required: Prowlarr's API refuses every call without one. APIKey string Timeout time.Duration } // Client searches one Prowlarr and fetches .torrent files through it. type Client struct { Config Config // HTTP is injectable for tests. Nil takes http.DefaultClient. HTTP *http.Client } type searchResult struct { Title string `json:"title"` InfoHash string `json:"infoHash"` GUID string `json:"guid"` MagnetURL string `json:"magnetUrl"` DownloadURL string `json:"downloadUrl"` Size int64 `json:"size"` Seeders int `json:"seeders"` Leechers int `json:"leechers"` Indexer string `json:"indexer"` PublishDate string `json:"publishDate"` Categories []struct { ID int `json:"id"` } `json:"categories"` } // Search asks every indexer in this Prowlarr. `type=search` is free text; // `type=music` takes artist and album separately, and a release name does not // reliably say which part is which. func (c *Client) Search(ctx context.Context, q indexer.Query) ([]indexer.Result, error) { params := url.Values{"query": {q.Text}, "type": {"search"}} for _, cat := range q.Categories { params.Add("categories", fmt.Sprint(cat)) } endpoint := strings.TrimRight(c.Config.BaseURL, "/") + SearchPath + "?" + params.Encode() body, err := c.get(ctx, endpoint) if err != nil { return nil, err } out, err := parseResults(body) if err != nil { return nil, err } for i := range out { out[i].Source = c.Config.Code } return out, nil } func parseResults(body []byte) ([]indexer.Result, error) { var rows []searchResult if err := json.Unmarshal(body, &rows); err != nil { return nil, fmt.Errorf("%w: parse search results: %v", indexer.ErrUnreachable, err) } out := make([]indexer.Result, 0, len(rows)) for _, row := range rows { title := strings.TrimSpace(row.Title) if title == "" { continue } r := indexer.Result{ Title: title, SizeBytes: row.Size, Seeders: row.Seeders, Peers: row.Leechers, Indexer: strings.TrimSpace(row.Indexer), Magnet: firstMagnet(row.MagnetURL, row.GUID), DownloadURL: strings.TrimSpace(row.DownloadURL), } if len(row.Categories) > 0 { r.Category = row.Categories[0].ID } r.InfoHash = indexer.NormalizeHash(row.InfoHash) if r.InfoHash == "" { r.InfoHash = indexer.MagnetHash(r.Magnet) } if r.InfoHash == "" && indexer.IsInfoHash(row.GUID) { r.InfoHash = indexer.NormalizeHash(row.GUID) } if t, err := time.Parse(time.RFC3339, strings.TrimSpace(row.PublishDate)); err == nil { r.Published = t } out = append(out, r) } return out, nil } func firstMagnet(candidates ...string) string { for _, c := range candidates { c = strings.TrimSpace(c) if strings.HasPrefix(c, "magnet:") { return c } } return "" } // Download fetches a result's .torrent through Prowlarr, which holds the // tracker credentials. A public tracker's link often redirects to a magnet, // which comes back as an *indexer.MagnetLink. func (c *Client) Download(ctx context.Context, r indexer.Result) ([]byte, error) { if r.DownloadURL == "" { return nil, fmt.Errorf("%w: %q has no download URL", indexer.ErrUnreachable, r.Title) } body, err := c.get(ctx, r.DownloadURL) if err != nil { return nil, err } return indexer.TorrentBody(r.Title, body) } func (c *Client) get(ctx context.Context, endpoint string) ([]byte, error) { if c.Config.BaseURL == "" || c.Config.APIKey == "" { return nil, indexer.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", indexer.ErrUnreachable, err) } req.Header.Set("User-Agent", indexer.UserAgent) // In a header, not ?apikey=, so the key stays out of URLs, logs and // error strings. req.Header.Set("X-Api-Key", c.Config.APIKey) req.Header.Set("Accept", "application/json") resp, body, err := indexer.Send(c.HTTP, req) if err != nil { return nil, err } if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return nil, fmt.Errorf("%w: HTTP %d", indexer.ErrAuth, resp.StatusCode) } if err := indexer.StatusError(resp, "Prowlarr"); err != nil { return nil, err } return body, nil }