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,198 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user