// 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 }