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>
199 lines
6.5 KiB
Go
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
|
|
}
|