322 lines
6.3 KiB
Go
322 lines
6.3 KiB
Go
package qbittorrent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
URL string `usage:"URL of qBittorrent instance (http://hostname:port)"`
|
|
Authenticate bool `usage:"Whether to authenticate"`
|
|
Username string `usage:"Username for auth - implies Authenticate=true"`
|
|
Password string `usage:"Password for auth - implies Authenticate=true"`
|
|
Name string `usage:"Name of instance, used as a shorter alternative for user"`
|
|
CookieTimeout time.Duration `usage:"Cookie refresh interval"`
|
|
}
|
|
|
|
type Instance struct {
|
|
URL *url.URL
|
|
Client *http.Client
|
|
Auth struct {
|
|
Enabled *bool
|
|
Cookie struct {
|
|
Timeout time.Duration
|
|
Expires time.Time
|
|
Mutex sync.Mutex
|
|
}
|
|
Credentials struct {
|
|
Username *string
|
|
Password *string
|
|
}
|
|
}
|
|
Name string
|
|
}
|
|
|
|
type Configs []*Config
|
|
type Hash string
|
|
type ContextKey *string
|
|
|
|
var (
|
|
Instances []*Instance
|
|
Torrents map[Hash]*Instance = map[Hash]*Instance{}
|
|
RoundRobinCounter int
|
|
|
|
Locks struct {
|
|
Instances sync.Mutex
|
|
Torrents sync.Mutex
|
|
RoundRobinCounter sync.Mutex
|
|
}
|
|
ContextKeyInstance = NewContextKey("instance")
|
|
)
|
|
|
|
func NewContextKey(key string) ContextKey {
|
|
return ContextKey(&key)
|
|
}
|
|
|
|
func (c Configs) Validate() (errs []error) {
|
|
|
|
g := sync.WaitGroup{}
|
|
d := sync.WaitGroup{}
|
|
|
|
errsChan := make(chan error)
|
|
instancesChan := make(chan *Instance)
|
|
|
|
for _, config := range c {
|
|
g.Add(1)
|
|
go func() {
|
|
defer g.Done()
|
|
instance, instanceErrors := config.New()
|
|
for _, err := range instanceErrors {
|
|
errsChan <- err
|
|
}
|
|
instancesChan <- instance
|
|
}()
|
|
}
|
|
|
|
d.Add(3)
|
|
go func() {
|
|
defer d.Done()
|
|
g.Wait()
|
|
close(errsChan)
|
|
close(instancesChan)
|
|
}()
|
|
go func() {
|
|
defer d.Done()
|
|
for instance := range instancesChan {
|
|
Instances = append(Instances, instance)
|
|
}
|
|
}()
|
|
go func() {
|
|
defer d.Done()
|
|
for err := range errsChan {
|
|
errs = append(errs, err)
|
|
}
|
|
}()
|
|
|
|
d.Wait()
|
|
|
|
log.Println("Config validated")
|
|
return
|
|
}
|
|
|
|
func (c *Config) New() (i *Instance, errs []error) {
|
|
|
|
i = &Instance{}
|
|
|
|
// URL
|
|
u, err := url.Parse(c.URL)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
} else {
|
|
i.URL = u
|
|
}
|
|
|
|
i.Auth.Enabled = &c.Authenticate
|
|
|
|
var jar *cookiejar.Jar
|
|
|
|
// Authentication
|
|
if *i.Auth.Enabled || c.Username != "" || c.Password != "" {
|
|
*i.Auth.Enabled = true
|
|
// Credentials
|
|
if c.Username == "" {
|
|
errs = append(errs, errors.New("empty username"))
|
|
} else {
|
|
i.Auth.Credentials.Username = &c.Username
|
|
}
|
|
if c.Password == "" {
|
|
errs = append(errs, errors.New("empty password"))
|
|
} else {
|
|
i.Auth.Credentials.Password = &c.Password
|
|
}
|
|
|
|
jar, err = cookiejar.New(nil)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
|
|
if c.CookieTimeout == 0 {
|
|
i.Auth.Cookie.Timeout = time.Minute * 15
|
|
} else {
|
|
i.Auth.Cookie.Timeout = c.CookieTimeout
|
|
}
|
|
|
|
}
|
|
|
|
if len(errs) == 0 {
|
|
i.Client = &http.Client{
|
|
Transport: http.DefaultTransport,
|
|
Jar: jar,
|
|
CheckRedirect: http.DefaultClient.CheckRedirect,
|
|
}
|
|
|
|
}
|
|
|
|
// Authentication
|
|
if *i.Auth.Enabled {
|
|
err := i.Login()
|
|
if err != nil {
|
|
errs = append(errs, errors.New("login failed"), err)
|
|
}
|
|
}
|
|
|
|
i.Name = c.Name
|
|
|
|
return
|
|
|
|
}
|
|
|
|
func (i *Instance) Login() error {
|
|
|
|
if !(*i.Auth.Enabled) {
|
|
return nil
|
|
}
|
|
|
|
i.Auth.Cookie.Mutex.Lock()
|
|
defer i.Auth.Cookie.Mutex.Unlock()
|
|
|
|
if i.Auth.Cookie.Expires.After(time.Now()) {
|
|
return nil
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Add("username", *(i.Auth.Credentials.Username))
|
|
form.Add("password", *(i.Auth.Credentials.Password))
|
|
|
|
req, err := i.MakeRequest(http.MethodPost, "/api/v2/auth/login", strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header = http.Header{}
|
|
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
newReq := i.PrepareRequest(req)
|
|
|
|
resp, err := i.Client.Do(newReq)
|
|
|
|
if err != nil || resp.StatusCode != http.StatusOK {
|
|
log.Println("Authentication failed (" + i.URL.Host + ")")
|
|
body := []byte("NONE")
|
|
status := "NONE"
|
|
if resp != nil {
|
|
body, _ = io.ReadAll(resp.Body)
|
|
status = strconv.Itoa(resp.StatusCode)
|
|
}
|
|
|
|
suffix := ""
|
|
if err != nil {
|
|
suffix = "\nError: " + err.Error()
|
|
}
|
|
|
|
return errors.New("Status Code:" + status + "\nBody:\n" + string(body) + suffix)
|
|
}
|
|
|
|
i.Auth.Cookie.Expires = time.Now().Add(i.Auth.Cookie.Timeout)
|
|
|
|
log.Println("Authenticated (" + i.URL.Host + ")")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
func (i *Instance) MakeRequest(method, url string, body io.Reader) (*http.Request, error) {
|
|
req, err := http.NewRequest(method, url, body)
|
|
req.URL.Host = i.URL.Host
|
|
return req, err
|
|
}
|
|
|
|
func (i *Instance) PrepareRequest(r *http.Request) (newReq *http.Request) {
|
|
|
|
ctx := r.Context()
|
|
|
|
newReq = r.Clone(context.WithValue(ctx, ContextKeyInstance, i))
|
|
|
|
newReq.RequestURI = ""
|
|
newReq.URL.Scheme = i.URL.Scheme
|
|
newReq.URL.Host = i.URL.Host
|
|
newReq.Host = i.URL.Host
|
|
|
|
if newReq.Header == nil {
|
|
newReq.Header = http.Header{}
|
|
}
|
|
|
|
newReq.Header.Set("Referer", i.URL.JoinPath("/").String())
|
|
newReq.Header.Del("Origin")
|
|
newReq.Header.Del("Cookie")
|
|
newReq.Header.Del("Referer-Policy")
|
|
newReq.Header.Del("Accept-Encoding")
|
|
newReq.Header.Del("X-Forwarded-Proto")
|
|
newReq.Header.Del("X-Forwarded-For")
|
|
newReq.Header.Del("X-Forwarded-Host")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
func LeastBusy() *Instance {
|
|
|
|
Locks.Torrents.Lock()
|
|
defer Locks.Torrents.Unlock()
|
|
|
|
Locks.Instances.Lock()
|
|
defer Locks.Instances.Unlock()
|
|
|
|
counts := map[*Instance]uint{}
|
|
|
|
for _, instance := range Instances {
|
|
counts[instance] = 0
|
|
}
|
|
|
|
for _, instance := range Torrents {
|
|
counts[instance] += 1
|
|
}
|
|
|
|
var minimum *uint
|
|
|
|
for _, c := range counts {
|
|
if minimum == nil {
|
|
minimum = &c
|
|
} else {
|
|
if c < *minimum {
|
|
minimum = &c
|
|
}
|
|
}
|
|
}
|
|
|
|
minimumInstances := []*Instance{}
|
|
|
|
for instance, count := range counts {
|
|
if count == *minimum {
|
|
minimumInstances = append(minimumInstances, instance)
|
|
}
|
|
}
|
|
|
|
slices.SortStableFunc(minimumInstances, func(a, b *Instance) int {
|
|
return strings.Compare(a.URL.Host, b.URL.Host)
|
|
})
|
|
|
|
return minimumInstances[0]
|
|
|
|
}
|
|
|
|
func NextRoundRobin() *Instance {
|
|
Locks.Instances.Lock()
|
|
defer Locks.Instances.Unlock()
|
|
Locks.RoundRobinCounter.Lock()
|
|
defer Locks.RoundRobinCounter.Unlock()
|
|
RoundRobinCounter += 1
|
|
if RoundRobinCounter >= len(Instances) {
|
|
RoundRobinCounter = 0
|
|
}
|
|
return Instances[RoundRobinCounter]
|
|
}
|