Files
bvandeusenandClaude Opus 5.5 a2e1890d4c
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
feat: indexers and download clients you add and configure (M502 #5518)
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>
2026-10-09 20:34:51 -04:00

341 lines
9.8 KiB
Go

package downloader
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
)
// Exchange drives FabledExchange's native /api/downloads: not its qBittorrent
// facade, so its own states, failure reasons and engine status come through.
type Exchange struct {
Config Config
// HTTP is injectable for tests. Nil takes a plain client.
HTTP *http.Client
}
type exchangeItem struct {
InfoHash string `json:"infohash"`
Name string `json:"name"`
Bytes int64 `json:"bytes"`
Length int64 `json:"length"`
Progress float64 `json:"progress"`
State string `json:"state"`
Peers int `json:"peers"`
Category string `json:"category"`
Tags []string `json:"tags"`
SavePath string `json:"save_path"`
AddedAt time.Time `json:"added_at"`
Failed bool `json:"failed"`
FailReason string `json:"fail_reason"`
Ratio float64 `json:"ratio"`
}
type exchangeSnapshot struct {
Enabled bool `json:"enabled"`
Running bool `json:"running"`
HeldReason string `json:"held_reason"`
InboundConns int64 `json:"inbound_conns"`
RestorePending int `json:"restore_pending"`
Downloads []exchangeItem `json:"downloads"`
}
func (c *Exchange) AddTorrent(ctx context.Context, a Add) error {
if a.Magnet == "" && len(a.Torrent) == 0 {
return fmt.Errorf("add: neither a magnet nor torrent bytes")
}
if a.SavePath == "" {
return fmt.Errorf("add: no save path, which would land the download outside the folder Minstrel imports from")
}
if len(a.Torrent) > 0 {
q := url.Values{"save_path": {a.SavePath}}
if c.Config.Category != "" {
q.Set("category", c.Config.Category)
}
for _, t := range a.Tags {
q.Add("tag", t)
}
if a.Paused {
q.Set("paused", "true")
}
_, err := c.call(ctx, http.MethodPost, "/api/downloads/torrent?"+q.Encode(), "application/x-bittorrent", a.Torrent)
return err
}
body, err := json.Marshal(map[string]any{
"magnet": a.Magnet, "category": c.Config.Category, "save_path": a.SavePath,
"tags": a.Tags, "paused": a.Paused,
})
if err != nil {
return fmt.Errorf("add: %w", err)
}
_, err = c.call(ctx, http.MethodPost, "/api/downloads", "application/json", body)
return err
}
// Torrents maps Exchange's downloads onto qBittorrent's shape. While Exchange
// is still restoring after a restart its list is incomplete, so it answers
// ErrRestoring rather than a list that looks like downloads vanished.
func (c *Exchange) Torrents(ctx context.Context, hashes ...string) ([]Torrent, error) {
snap, err := c.snapshot(ctx)
if err != nil {
return nil, err
}
if snap.RestorePending > 0 {
return nil, fmt.Errorf("%w (%d still to come back)", ErrRestoring, snap.RestorePending)
}
want := map[string]bool{}
for _, h := range hashes {
want[strings.ToLower(strings.TrimSpace(h))] = true
}
down := ""
switch {
case snap.Running:
case !snap.Enabled:
down = "the download engine is switched off in FabledExchange"
case snap.HeldReason != "":
down = "the download engine is held (" + snap.HeldReason + ") and resumes by itself"
default:
down = "the download engine is enabled but not running; FabledExchange is retrying it"
}
out := make([]Torrent, 0, len(snap.Downloads))
for _, it := range snap.Downloads {
if c.Config.Category != "" && it.Category != c.Config.Category {
continue
}
t := asTorrent(it)
t.EngineDown = down
if len(want) > 0 && !want[t.Hash] {
continue
}
out = append(out, t)
}
return out, nil
}
func asTorrent(it exchangeItem) Torrent {
content := it.SavePath
if it.Name != "" {
content = path.Join(it.SavePath, it.Name)
}
left := max(it.Length-it.Bytes, 0)
state := exchangeState(it.State, it.Progress)
if it.Failed {
state = "error"
}
var added int64
if !it.AddedAt.IsZero() {
added = it.AddedAt.Unix()
}
return Torrent{
Hash: strings.ToLower(it.InfoHash), Name: it.Name, Size: it.Length,
Progress: it.Progress, State: state, Category: it.Category,
Tags: strings.Join(it.Tags, ","), SavePath: it.SavePath, ContentPath: content,
AddedOn: added, NumLeechs: it.Peers, AmountLeft: left,
FailReason: it.FailReason, Ratio: it.Ratio,
}
}
// exchangeState names Exchange's state in qBittorrent's vocabulary.
func exchangeState(tier string, progress float64) string {
switch tier {
case "fast", "slow":
return "downloading"
case "metadata":
return "metaDL"
case "stalled":
return "stalledDL"
case "queued":
return "queuedDL"
case "seeding":
return "uploading"
case "relocating":
return "moving"
case "paused":
if progress >= 1 {
return "pausedUP"
}
return "pausedDL"
}
return "unknown"
}
// Connected is a running engine that has had an inbound connection: the
// nearest Exchange comes to qBittorrent's "connected".
func (c *Exchange) Connected(ctx context.Context) (bool, error) {
snap, err := c.snapshot(ctx)
if err != nil {
return false, err
}
return snap.Running && snap.InboundConns > 0, nil
}
func (c *Exchange) Stop(ctx context.Context, hashes ...string) error {
return c.each(ctx, hashes, func(h string) error {
_, err := c.call(ctx, http.MethodPost, "/api/downloads/"+url.PathEscape(h)+"/pause", "", nil)
return err
})
}
func (c *Exchange) Start(ctx context.Context, hashes ...string) error {
return c.each(ctx, hashes, func(h string) error {
_, err := c.call(ctx, http.MethodPost, "/api/downloads/"+url.PathEscape(h)+"/resume", "", nil)
return err
})
}
func (c *Exchange) SkipFiles(ctx context.Context, hash string, indices []int) error {
return c.filePriority(ctx, hash, indices, "skip")
}
func (c *Exchange) KeepFiles(ctx context.Context, hash string, indices []int) error {
return c.filePriority(ctx, hash, indices, "normal")
}
func (c *Exchange) filePriority(ctx context.Context, hash string, indices []int, priority string) error {
if len(indices) == 0 {
return nil
}
body, err := json.Marshal(map[string]any{"indices": indices, "priority": priority})
if err != nil {
return err
}
_, err = c.call(ctx, http.MethodPatch, "/api/downloads/"+url.PathEscape(hash)+"/files", "application/json", body)
return err
}
func (c *Exchange) Files(ctx context.Context, hash string) ([]File, error) {
raw, err := c.files(ctx, hash)
if err != nil {
return nil, err
}
out := make([]File, len(raw))
for i, f := range raw {
out[i] = File{Index: f.Index, Path: f.Path, Size: f.Length}
}
return out, nil
}
func (c *Exchange) FileStates(ctx context.Context, hash string) ([]FileState, error) {
raw, err := c.files(ctx, hash)
if err != nil {
return nil, err
}
out := make([]FileState, len(raw))
for i, f := range raw {
if f.Bytes == nil {
return nil, fmt.Errorf("%w: FabledExchange does not say how much of each file it has", ErrUnsupported)
}
out[i] = FileState{Path: f.Path, Size: f.Length, Done: *f.Bytes, Skipped: f.Priority == "skip"}
}
return out, nil
}
type exchangeFile struct {
Index int `json:"index"`
Path string `json:"path"`
Length int64 `json:"length"`
Bytes *int64 `json:"bytes"`
Priority string `json:"priority"`
}
func (c *Exchange) files(ctx context.Context, hash string) ([]exchangeFile, error) {
body, err := c.call(ctx, http.MethodGet, "/api/downloads/"+url.PathEscape(hash), "", nil)
if err != nil {
return nil, err
}
var detail struct {
Files []exchangeFile `json:"files"`
}
if err := json.Unmarshal(body, &detail); err != nil {
return nil, fmt.Errorf("%w: parse download detail: %v", ErrUnreachable, err)
}
return detail.Files, nil
}
func (c *Exchange) Reannounce(context.Context, ...string) error {
return fmt.Errorf("%w: FabledExchange schedules its own announces", ErrUnsupported)
}
func (c *Exchange) Recheck(context.Context, ...string) error {
return fmt.Errorf("%w: FabledExchange has no recheck", ErrUnsupported)
}
func (c *Exchange) Delete(ctx context.Context, deleteFiles bool, hashes ...string) error {
return c.each(ctx, hashes, func(h string) error {
p := "/api/downloads/" + url.PathEscape(h)
if deleteFiles {
p += "?delete_files=true"
}
_, err := c.call(ctx, http.MethodDelete, p, "", nil)
return err
})
}
func (c *Exchange) each(ctx context.Context, hashes []string, do func(string) error) error {
for _, h := range hashes {
if ctx.Err() != nil {
return ctx.Err()
}
if err := do(strings.ToLower(strings.TrimSpace(h))); err != nil {
return err
}
}
return nil
}
func (c *Exchange) snapshot(ctx context.Context) (exchangeSnapshot, error) {
var snap exchangeSnapshot
body, err := c.call(ctx, http.MethodGet, "/api/downloads", "", nil)
if err != nil {
return snap, err
}
if err := json.Unmarshal(body, &snap); err != nil {
return snap, fmt.Errorf("%w: parse download list: %v", ErrUnreachable, err)
}
return snap, nil
}
func (c *Exchange) call(ctx context.Context, method, p, contentType string, body []byte) ([]byte, error) {
if c.Config.BaseURL == "" {
return nil, ErrUnconfigured
}
httpc := c.HTTP
if httpc == nil {
httpc = &http.Client{}
}
status, out, err := send(ctx, httpc, c.Config.Timeout, method,
strings.TrimRight(c.Config.BaseURL, "/")+p, contentType, body, func(h http.Header) {
h.Set("Accept", "application/json")
if c.Config.APIKey != "" {
h.Set("Authorization", "Bearer "+c.Config.APIKey)
}
})
if err != nil {
return nil, err
}
if status >= 200 && status < 300 {
return out, nil
}
var e struct {
Error string `json:"error"`
Reason string `json:"reason"`
}
_ = json.Unmarshal(out, &e)
said := strings.TrimSpace(e.Error)
if said == "" {
said = fmt.Sprintf("HTTP %d", status)
}
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return nil, fmt.Errorf("%w: FabledExchange refused the API key (%s)", ErrAuth, said)
case status == http.StatusConflict && e.Reason == "duplicate":
return nil, fmt.Errorf("%w: %s", ErrDuplicate, said)
}
return nil, fmt.Errorf("%w: %s", ErrUnreachable, said)
}