feat: indexers and download clients you add and configure (M502 #5518)
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped
Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.
- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
session) and FabledExchange, one entry with a switch per job:
downloads (/api/downloads), search (/api/search) and release details
(/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
A download client's Test lists its torrents and checks the downloads
folder is visible and writable from Minstrel; an Exchange's Test
reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
client's own name for it); search sources carry searches per hour.
Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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 <error> 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 <error code=… description=…/> 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), "<error") {
|
||||
return nil
|
||||
}
|
||||
var doc struct {
|
||||
XMLName xml.Name `xml:"error"`
|
||||
Code string `xml:"code,attr"`
|
||||
Description string `xml:"description,attr"`
|
||||
}
|
||||
if err := xml.Unmarshal(body, &doc); err != nil {
|
||||
return nil
|
||||
}
|
||||
if doc.Code == "" && doc.Description == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(doc.Code, "1") && len(doc.Code) == 3 {
|
||||
return fmt.Errorf("%w: indexer error %s: %s", ErrAuth, doc.Code, doc.Description)
|
||||
}
|
||||
return fmt.Errorf("%w: indexer error %s: %s", ErrUnreachable, doc.Code, doc.Description)
|
||||
}
|
||||
|
||||
// The Torznab feed: RSS 2.0 with torznab:attr for what RSS has no field for.
|
||||
type feed struct {
|
||||
Channel struct {
|
||||
Items []item `xml:"item"`
|
||||
} `xml:"channel"`
|
||||
}
|
||||
|
||||
type item struct {
|
||||
Title string `xml:"title"`
|
||||
GUID string `xml:"guid"`
|
||||
Link string `xml:"link"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Size int64 `xml:"size"`
|
||||
Enclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Length int64 `xml:"length,attr"`
|
||||
} `xml:"enclosure"`
|
||||
Attrs []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Value string `xml:"value,attr"`
|
||||
} `xml:"attr"`
|
||||
}
|
||||
|
||||
func (i item) attr(name string) string {
|
||||
for _, a := range i.Attrs {
|
||||
if strings.EqualFold(a.Name, name) {
|
||||
return a.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseFeed(body []byte) ([]Result, error) {
|
||||
var f feed
|
||||
if err := xml.Unmarshal(body, &f); err != nil {
|
||||
return nil, fmt.Errorf("%w: parse feed: %v", ErrUnreachable, err)
|
||||
}
|
||||
out := make([]Result, 0, len(f.Channel.Items))
|
||||
for _, it := range f.Channel.Items {
|
||||
r := Result{
|
||||
Title: strings.TrimSpace(it.Title),
|
||||
SizeBytes: it.Size,
|
||||
Indexer: it.attr("indexer"),
|
||||
Seeders: atoi(it.attr("seeders")),
|
||||
Peers: atoi(it.attr("peers")),
|
||||
Category: atoi(it.attr("category")),
|
||||
}
|
||||
if r.Title == "" {
|
||||
continue
|
||||
}
|
||||
if r.SizeBytes == 0 {
|
||||
r.SizeBytes = it.Enclosure.Length
|
||||
}
|
||||
// The link is a magnet or a .torrent URL; Prowlarr puts it in <link>
|
||||
// and repeats it in <enclosure url>, 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
|
||||
}
|
||||
Reference in New Issue
Block a user