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

199 lines
6.5 KiB
Go

// Package downloader drives the download clients Minstrel hands a grab to
// (M502 #5518): qBittorrent's WebUI v2, and FabledExchange's native
// /api/downloads. Ported from Fabled Librarian's internal/downloader.
//
// Both answer through one Driver, and Torrent is qBittorrent's shape: the
// Exchange driver maps its own downloads onto it, so the grab, the importer
// and the stall watchdog read one kind of record whichever client holds the
// torrent.
package downloader
import (
"context"
"errors"
"fmt"
"time"
)
// Protocols a download client row may name.
const (
KindQBittorrent = "qbittorrent"
KindExchange = "exchange"
)
// ErrUnconfigured is a client with no address.
var ErrUnconfigured = errors.New("download client is not configured")
// ErrUnreachable is a call that could not be made, or a refusal that is not
// about credentials.
var ErrUnreachable = errors.New("download client unreachable")
// ErrAuth is credentials the client refused.
var ErrAuth = errors.New("download client refused the credentials")
// ErrRestoring is an Exchange still bringing its downloads back after a
// restart: its list is incomplete, so nothing may be concluded from it.
var ErrRestoring = errors.New("download client is still restoring its downloads after a restart")
// ErrDuplicate is a torrent the client already holds.
var ErrDuplicate = errors.New("download client already holds this torrent")
// ErrUnsupported is a call this client has no equivalent for.
var ErrUnsupported = errors.New("download client cannot do that")
// UserAgent names Minstrel in the client's logs.
const UserAgent = "Minstrel (self-hosted music server; download client)"
// DefaultTimeout is what an unset Timeout means: a download client answers
// from its own state.
const DefaultTimeout = 15 * time.Second
// Config is one download client.
type Config struct {
BaseURL string
// Username and Password are qBittorrent's. Both may be empty for a WebUI
// that bypasses auth for local addresses.
Username string
Password string
// APIKey is Exchange's, sent as a bearer only when set.
APIKey string
// Category files everything Minstrel adds, so its torrents can be told
// from the operator's own.
Category string
Timeout time.Duration
}
// Driver is what Minstrel needs from a download client.
type Driver interface {
AddTorrent(ctx context.Context, a Add) error
// Torrents lists this client's torrents in Config.Category, or only the
// given hashes.
Torrents(ctx context.Context, hashes ...string) ([]Torrent, error)
// Connected says whether the client can reach any peer at all.
Connected(ctx context.Context) (bool, error)
Stop(ctx context.Context, hashes ...string) error
Start(ctx context.Context, hashes ...string) error
Reannounce(ctx context.Context, hashes ...string) error
Recheck(ctx context.Context, hashes ...string) error
Delete(ctx context.Context, deleteFiles bool, hashes ...string) error
}
// FileSkipper sets which files of a torrent are downloaded.
type FileSkipper interface {
SkipFiles(ctx context.Context, hash string, indices []int) error
KeepFiles(ctx context.Context, hash string, indices []int) error
}
// File is one file inside a torrent.
type File struct {
Index int
Path string
Size int64
}
// FileLister lists a torrent's files.
type FileLister interface {
Files(ctx context.Context, hash string) ([]File, error)
}
// FileState is one file's progress.
type FileState struct {
Path string
Size int64
Done int64
Skipped bool
}
// Unfinished says whether the file is wanted and not yet whole.
func (f FileState) Unfinished() bool { return !f.Skipped && f.Done < f.Size }
// FileProgress reports per-file progress.
type FileProgress interface {
FileStates(ctx context.Context, hash string) ([]FileState, error)
}
var (
_ Driver = (*Client)(nil)
_ Driver = (*Exchange)(nil)
_ FileSkipper = (*Client)(nil)
_ FileSkipper = (*Exchange)(nil)
_ FileSkipper = (*mapped)(nil)
_ FileLister = (*Client)(nil)
_ FileLister = (*Exchange)(nil)
_ FileLister = (*mapped)(nil)
_ FileProgress = (*Client)(nil)
_ FileProgress = (*Exchange)(nil)
_ FileProgress = (*mapped)(nil)
)
// For builds the driver a protocol names.
func For(protocol string, cfg Config) (Driver, error) {
switch protocol {
case KindQBittorrent:
return &Client{Config: cfg}, nil
case KindExchange:
return &Exchange{Config: cfg}, nil
}
return nil, fmt.Errorf("download client %q is not one Minstrel can drive", protocol)
}
// Torrent is one torrent in qBittorrent's JSON shape.
type Torrent struct {
Hash string `json:"hash"`
Name string `json:"name"`
Size int64 `json:"size"`
Progress float64 `json:"progress"`
State string `json:"state"`
Category string `json:"category"`
Tags string `json:"tags"`
SavePath string `json:"save_path"`
ContentPath string `json:"content_path"`
AddedOn int64 `json:"added_on"`
NumSeeds int `json:"num_seeds"`
NumLeechs int `json:"num_leechs"`
AmountLeft int64 `json:"amount_left"`
Ratio float64 `json:"ratio"`
DLSpeed int64 `json:"dlspeed"`
ETA int64 `json:"eta"`
// EngineDown is why the client's whole engine is not moving anything, in
// words; empty when it is running. Exchange says so; qBittorrent does not.
EngineDown string `json:"-"`
// FailReason is the client's own reason for a failed torrent.
FailReason string `json:"-"`
}
// Done says the torrent is whole.
func (t Torrent) Done() bool { return t.Progress >= 1 }
// Failed says the client gave up on it.
func (t Torrent) Failed() bool { return t.State == "error" || t.State == "missingFiles" }
// Fetching says it is still fetching metadata.
func (t Torrent) Fetching() bool { return t.State == "metaDL" || t.State == "forcedMetaDL" }
// Alone says nobody is sharing an unfinished torrent.
func (t Torrent) Alone() bool { return !t.Done() && t.NumSeeds == 0 && t.NumLeechs == 0 }
// Added is when the client took it.
func (t Torrent) Added() time.Time {
if t.AddedOn <= 0 {
return time.Time{}
}
return time.Unix(t.AddedOn, 0)
}
// Add is one torrent to hand a client: a magnet or the .torrent bytes.
type Add struct {
Magnet string
Torrent []byte
// SavePath is where it lands, as Minstrel sees it; Mapped translates it.
// Required: a download with no save path lands outside the folder
// Minstrel imports from.
SavePath string
Tags []string
Paused bool
// StopAtMetadata asks qBittorrent to stop once it has the file list, so
// unwanted files can be skipped before any data moves.
StopAtMetadata bool
}