Files
minstrel/internal/downloader/driver.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -04:00

198 lines
6.4 KiB
Go

// Package downloader drives the download clients Minstrel hands a grab to:
// qBittorrent's WebUI v2, and FabledExchange's native /api/downloads.
//
// 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 anything else in the client.
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
}