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>
341 lines
9.8 KiB
Go
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)
|
|
}
|