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,177 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package prowlarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
||||
)
|
||||
|
||||
const sampleResults = `[
|
||||
{"title": "Radiohead - In Rainbows (2007) [FLAC]", "infoHash": "AABBCCDDEEFF00112233445566778899AABBCCDD",
|
||||
"downloadUrl": "http://prowlarr/1/download?link=x", "size": 400000000, "seeders": 30, "leechers": 4,
|
||||
"indexer": "RED", "publishDate": "2026-10-01T12:00:00Z", "categories": [{"id": 3040}]},
|
||||
{"title": "Radiohead - Amnesiac", "guid": "magnet:?xt=urn:btih:ffeeddccbbaa99887766554433221100ffeeddcc"},
|
||||
{"title": ""}
|
||||
]`
|
||||
|
||||
func TestSearch_AsksTheNativeAPIWithTheKeyInAHeader(t *testing.T) {
|
||||
reqs := make(chan *http.Request, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reqs <- r
|
||||
_, _ = w.Write([]byte(sampleResults))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &Client{Config: Config{Code: "prowlarr", BaseURL: srv.URL + "/", APIKey: "secret"}}
|
||||
results, err := c.Search(context.Background(), indexer.Query{Text: "radiohead", Categories: []int{3000}})
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
got := <-reqs
|
||||
if got.URL.Path != SearchPath || got.URL.Query().Get("type") != "search" || got.URL.Query().Get("categories") != "3000" {
|
||||
t.Errorf("request = %s, want %s with type=search and the categories", got.URL, SearchPath)
|
||||
}
|
||||
if got.Header.Get("X-Api-Key") != "secret" || got.URL.Query().Get("apikey") != "" {
|
||||
t.Error("the key belongs in X-Api-Key, never in the URL")
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results = %d, want 2", len(results))
|
||||
}
|
||||
if r := results[0]; r.InfoHash != "aabbccddeeff00112233445566778899aabbccdd" || r.Peers != 4 ||
|
||||
r.Category != 3040 || r.Source != "prowlarr" || r.Published.IsZero() {
|
||||
t.Errorf("first = %+v", r)
|
||||
}
|
||||
if r := results[1]; r.Magnet == "" || r.InfoHash != "ffeeddccbbaa99887766554433221100ffeeddcc" {
|
||||
t.Errorf("second = %+v, want the GUID's magnet and its hash", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_Refusals(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
status int
|
||||
want error
|
||||
}{
|
||||
"a refused key": {http.StatusUnauthorized, indexer.ErrAuth},
|
||||
"an outage": {http.StatusBadGateway, indexer.ErrUnreachable},
|
||||
} {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(tc.status)
|
||||
}))
|
||||
c := &Client{Config: Config{BaseURL: srv.URL, APIKey: "k"}}
|
||||
_, err := c.Search(context.Background(), indexer.Query{Text: "x"})
|
||||
srv.Close()
|
||||
if !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s: err = %v, want %v", name, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_NoKeyIsUnconfigured(t *testing.T) {
|
||||
c := &Client{Config: Config{BaseURL: "http://prowlarr:9696"}}
|
||||
if _, err := c.Search(context.Background(), indexer.Query{Text: "x"}); !errors.Is(err, indexer.ErrUnconfigured) {
|
||||
t.Fatalf("err = %v, want ErrUnconfigured", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user