Files
yellowjacket/backend/download/provider_prowlarr.go
yonluandClaude Sonnet 5 65333857e2
Build & publish Arch package / arch-package (push) Successful in 2m2s
Search index maintenance / maintain-index (push) Successful in 7s
refactor(download): rename Want/Request to Request/Download, unify downloads flow, add auto-download guardrails
The durable "I asked for this" record was called Want, and the one-shot
search-and-grab attempt was called Request — names that didn't match
what either actually did. Want is now Request, and the old Request/Item
is now Download/DownloadItem, with a table-rename migration
(download_wants -> download_requests, old download_requests ->
download_downloads) safe against both fresh installs and existing data.

Every anchored manual download now upserts/reuses a durable Request
before running, so a "download now" that finds nothing is picked up by
the background reconciler automatically instead of just failing with
no trace — the gap that caused this session's repeated "no candidates
found" failures on the same album.

Also adds auto-download guardrails (file-size min/max with a preferred
target, allowed file types) that gate what the pipeline may grab
unattended, live-editable from a new settings section. The frontend's
wanted-view becomes downloads-view, with a new Downloads tab showing
attempt/transfer history that previously had no UI at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-10 14:35:57 -04:00

319 lines
7.6 KiB
Go

package download
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strconv"
"strings"
"time"
)
// Prowlarr is the one provider that cannot finish a job by itself: it
// searches dozens of indexers and hands back magnet links and NZB URLs,
// which some other client has to actually fetch. That is the whole
// reason the role interfaces are separate — Prowlarr implements
// Searcher and nothing else, and the pipeline pairs its results with
// whichever enabled transport handles the protocol.
//
// Its search results also carry no file list. A torrent is one opaque
// blob until it is fetched, so match scoring here has only the release
// title to work with, and candidates are marked accordingly rather than
// pretending to know what is inside.
// Prowlarr provider errors.
var (
// ErrProwlarrUnreachable means the instance did not answer.
ErrProwlarrUnreachable = errors.New("prowlarr is unreachable")
// ErrProwlarrAuth means the API key was rejected.
ErrProwlarrAuth = errors.New("prowlarr rejected the API key")
// ErrProwlarrNoIndexers means nothing is configured to search.
ErrProwlarrNoIndexers = errors.New("prowlarr has no enabled indexers")
)
// prowlarrHTTPTimeout bounds one API call. Indexer fan-out is slow, so
// this is longer than the other adapters'.
const prowlarrHTTPTimeout = 45 * time.Second
// prowlarrMusicCategory is Newznab's music category. Searching without
// it returns every match across film and software too.
const prowlarrMusicCategory = "3000"
// prowlarrMaxResults caps how many results are turned into candidates.
const prowlarrMaxResults = 40
func init() {
Register(
Descriptor{
Kind: KindProwlarr,
Name: "Prowlarr",
Summary: "Search many torrent and usenet indexers at once. " +
"Needs a download client (qBittorrent or SABnzbd) to fetch results.",
RequiresExternal: "Prowlarr",
Caps: Caps{
CanSearch: true,
},
Fields: []Field{
{
Key: "url",
Label: "Prowlarr URL",
Placeholder: "http://localhost:9696",
Required: true,
Default: "http://localhost:9696",
},
{
Key: "apiKey",
Label: "API key",
Secret: true,
Required: true,
Help: "Prowlarr → Settings → General → API Key.",
},
{
Key: "indexerIds",
Label: "Indexer IDs",
Help: "Comma-separated numeric IDs to restrict the search to. " +
"Leave blank to search all enabled indexers.",
},
{
Key: "minSeeders",
Label: "Minimum seeders",
Help: "Torrent results below this are hidden. " +
"Defaults to 1.",
Default: "1",
},
},
},
newProwlarr,
)
}
// prowlarr is the Prowlarr search provider.
type prowlarr struct {
info ProviderInfo
logger *slog.Logger
client *apiClient
indexerIDs []string
minSeeders int
}
// newProwlarr builds the provider from config.
func newProwlarr(
cfg Config,
secrets SecretLookup,
logger *slog.Logger,
) (Provider, error) {
base := strings.TrimRight(cfg.Setting("url", ""), "/")
if base == "" {
return nil, fmt.Errorf("%w: Prowlarr URL is required", ErrNotConfigured)
}
apiKey := ""
if secrets != nil {
key, err := secrets("apiKey")
if err != nil {
return nil, fmt.Errorf("%w: no API key stored", ErrNotConfigured)
}
apiKey = key
}
minSeeders, err := strconv.Atoi(cfg.Setting("minSeeders", "1"))
if err != nil {
minSeeders = 1
}
var indexers []string
for _, id := range strings.Split(cfg.Setting("indexerIds", ""), ",") {
if trimmed := strings.TrimSpace(id); trimmed != "" {
indexers = append(indexers, trimmed)
}
}
return &prowlarr{
info: ProviderInfo{
ID: cfg.ID,
Kind: KindProwlarr,
Name: cfg.Name,
Enabled: cfg.Enabled,
Priority: cfg.Priority,
Caps: Caps{CanSearch: true},
},
logger: logger.With("provider", "prowlarr"),
client: newAPIClient(
base, "X-Api-Key", apiKey, prowlarrHTTPTimeout,
ErrProwlarrUnreachable, ErrProwlarrAuth,
),
indexerIDs: indexers,
minSeeders: minSeeders,
}, nil
}
// Info returns the provider's identity.
func (p *prowlarr) Info() ProviderInfo {
return p.info
}
// Close is a no-op.
func (p *prowlarr) Close() error {
return nil
}
// Check verifies the instance answers and has something to search.
func (p *prowlarr) Check(ctx context.Context) error {
var status struct {
Version string `json:"version"`
}
if err := p.client.get(ctx, "/api/v1/system/status", &status); err != nil {
return err
}
var indexers []struct {
ID int `json:"id"`
Enable bool `json:"enable"`
}
if err := p.client.get(ctx, "/api/v1/indexer", &indexers); err != nil {
return err
}
for _, i := range indexers {
if i.Enable {
return nil
}
}
return ErrProwlarrNoIndexers
}
// prowlarrResult is one indexer hit.
type prowlarrResult struct {
GUID string `json:"guid"`
Title string `json:"title"`
Indexer string `json:"indexer"`
Size int64 `json:"size"`
Seeders int `json:"seeders"`
Leechers int `json:"leechers"`
Protocol string `json:"protocol"` // "torrent" or "usenet"
DownloadURL string `json:"downloadUrl"`
MagnetURL string `json:"magnetUrl"`
InfoHash string `json:"infoHash"`
}
// Search queries every configured indexer through Prowlarr.
func (p *prowlarr) Search(
ctx context.Context,
dl Download,
) ([]Candidate, error) {
query := url.Values{}
query.Set("query", dl.SearchText())
query.Set("categories", prowlarrMusicCategory)
query.Set("type", "search")
for _, id := range p.indexerIDs {
query.Add("indexerIds", id)
}
var results []prowlarrResult
endpoint := "/api/v1/search?" + query.Encode()
if err := p.client.get(ctx, endpoint, &results); err != nil {
return nil, err
}
out := make([]Candidate, 0, len(results))
for _, r := range results {
if len(out) >= prowlarrMaxResults {
break
}
protocol := protocolFor(r.Protocol)
if protocol == ProtocolDirect {
continue
}
// A torrent with no seeders will never finish. Offering it
// wastes the user's pick on something that cannot complete.
if protocol == ProtocolTorrent && r.Seeders < p.minSeeders {
continue
}
link := r.MagnetURL
if link == "" {
link = r.DownloadURL
}
if link == "" {
continue
}
out = append(out, Candidate{
ID: "prowlarr:" + r.GUID,
Kind: KindProwlarr,
Protocol: protocol,
Title: r.Title,
Origin: r.Indexer,
// Indexer results are opaque before they are fetched: there
// is no file list, so no per-file scoring is possible and
// the ranker works from the release title alone.
Files: nil,
TotalSize: r.Size,
Health: swarmHealth(protocol, r.Seeders),
Payload: map[string]string{
"link": link,
"indexer": r.Indexer,
"infoHash": r.InfoHash,
},
})
}
return out, nil
}
// protocolFor maps Prowlarr's protocol string onto ours.
func protocolFor(s string) Protocol {
switch strings.ToLower(s) {
case "torrent":
return ProtocolTorrent
case "usenet":
return ProtocolUsenet
default:
return ProtocolDirect
}
}
// swarmHealth scores availability, in 0..1. Usenet has no swarm: a
// retained article either downloads at full speed or is gone, so it
// gets a flat, confident score.
func swarmHealth(protocol Protocol, seeders int) float64 {
if protocol == ProtocolUsenet {
return 0.85
}
// Seeder counts have sharply diminishing returns — the difference
// between 1 and 10 is enormous, between 100 and 500 irrelevant.
switch {
case seeders <= 0:
return 0.05
case seeders == 1:
return 0.3
case seeders < 5:
return 0.5
case seeders < 20:
return 0.75
case seeders < 100:
return 0.9
default:
return 1.0
}
}