234 lines
5.3 KiB
Go
234 lines
5.3 KiB
Go
package arr
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"github.com/sirrobot01/decypharr/internal/config"
|
|
"github.com/sirrobot01/decypharr/internal/logger"
|
|
"github.com/sirrobot01/decypharr/internal/request"
|
|
)
|
|
|
|
// Type is a type of arr
|
|
type Type string
|
|
|
|
var sharedClient = &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
},
|
|
Timeout: 60 * time.Second,
|
|
}
|
|
|
|
const (
|
|
Sonarr Type = "sonarr"
|
|
Radarr Type = "radarr"
|
|
Lidarr Type = "lidarr"
|
|
Readarr Type = "readarr"
|
|
)
|
|
|
|
type Arr struct {
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
Token string `json:"token"`
|
|
Type Type `json:"type"`
|
|
Cleanup bool `json:"cleanup"`
|
|
SkipRepair bool `json:"skip_repair"`
|
|
DownloadUncached *bool `json:"download_uncached"`
|
|
SelectedDebrid string `json:"selected_debrid,omitempty"` // The debrid service selected for this arr
|
|
Source string `json:"source,omitempty"` // The source of the arr, e.g. "auto", "manual". Auto means it was automatically detected from the arr
|
|
}
|
|
|
|
func New(name, host, token string, cleanup, skipRepair bool, downloadUncached *bool, selectedDebrid, source string) *Arr {
|
|
return &Arr{
|
|
Name: name,
|
|
Host: host,
|
|
Token: strings.TrimSpace(token),
|
|
Type: InferType(host, name),
|
|
Cleanup: cleanup,
|
|
SkipRepair: skipRepair,
|
|
DownloadUncached: downloadUncached,
|
|
SelectedDebrid: selectedDebrid,
|
|
Source: source,
|
|
}
|
|
}
|
|
|
|
func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Response, error) {
|
|
if a.Token == "" || a.Host == "" {
|
|
return nil, fmt.Errorf("arr not configured")
|
|
}
|
|
url, err := request.JoinURL(a.Host, endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var body io.Reader
|
|
if payload != nil {
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequest(method, url, body)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Api-Key", a.Token)
|
|
|
|
var resp *http.Response
|
|
|
|
for attempts := 0; attempts < 5; attempts++ {
|
|
resp, err = sharedClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If we got a 401, wait briefly and retry
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
resp.Body.Close() // Don't leak response bodies
|
|
if attempts < 4 { // Don't sleep on the last attempt
|
|
time.Sleep(time.Duration(attempts+1) * 100 * time.Millisecond)
|
|
continue
|
|
}
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
|
|
func (a *Arr) Validate() error {
|
|
if a.Token == "" || a.Host == "" {
|
|
return nil
|
|
}
|
|
resp, err := a.Request("GET", "/api/v3/health", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
// If response is not 200 or 404(this is the case for Lidarr, etc), return an error
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound {
|
|
return fmt.Errorf("failed to validate arr %s: %s", a.Name, resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type Storage struct {
|
|
Arrs map[string]*Arr // name -> arr
|
|
mu sync.Mutex
|
|
logger zerolog.Logger
|
|
}
|
|
|
|
func (s *Storage) Cleanup() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.Arrs = make(map[string]*Arr)
|
|
}
|
|
|
|
func InferType(host, name string) Type {
|
|
switch {
|
|
case strings.Contains(host, "sonarr") || strings.Contains(name, "sonarr"):
|
|
return Sonarr
|
|
case strings.Contains(host, "radarr") || strings.Contains(name, "radarr"):
|
|
return Radarr
|
|
case strings.Contains(host, "lidarr") || strings.Contains(name, "lidarr"):
|
|
return Lidarr
|
|
case strings.Contains(host, "readarr") || strings.Contains(name, "readarr"):
|
|
return Readarr
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func NewStorage() *Storage {
|
|
arrs := make(map[string]*Arr)
|
|
for _, a := range config.Get().Arrs {
|
|
if a.Host == "" || a.Token == "" || a.Name == "" {
|
|
continue // Skip if host or token is not set
|
|
}
|
|
name := a.Name
|
|
arrs[name] = New(name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid, a.Source)
|
|
}
|
|
return &Storage{
|
|
Arrs: arrs,
|
|
logger: logger.New("arr"),
|
|
}
|
|
}
|
|
|
|
func (s *Storage) AddOrUpdate(arr *Arr) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if arr.Host == "" || arr.Token == "" || arr.Name == "" {
|
|
return
|
|
}
|
|
s.Arrs[arr.Name] = arr
|
|
}
|
|
|
|
func (s *Storage) Get(name string) *Arr {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.Arrs[name]
|
|
}
|
|
|
|
func (s *Storage) GetAll() []*Arr {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
arrs := make([]*Arr, 0, len(s.Arrs))
|
|
for _, arr := range s.Arrs {
|
|
arrs = append(arrs, arr)
|
|
}
|
|
return arrs
|
|
}
|
|
|
|
func (s *Storage) StartWorker(ctx context.Context) error {
|
|
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
|
|
select {
|
|
case <-ticker.C:
|
|
s.cleanupArrsQueue()
|
|
case <-ctx.Done():
|
|
ticker.Stop()
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Storage) cleanupArrsQueue() {
|
|
arrs := make([]*Arr, 0)
|
|
for _, arr := range s.Arrs {
|
|
if !arr.Cleanup {
|
|
continue
|
|
}
|
|
arrs = append(arrs, arr)
|
|
}
|
|
if len(arrs) > 0 {
|
|
for _, arr := range arrs {
|
|
if err := arr.CleanupQueue(); err != nil {
|
|
s.logger.Error().Err(err).Msgf("Failed to cleanup arr %s", arr.Name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *Arr) Refresh() {
|
|
payload := struct {
|
|
Name string `json:"name"`
|
|
}{
|
|
Name: "RefreshMonitoredDownloads",
|
|
}
|
|
|
|
_, _ = a.Request(http.MethodPost, "api/v3/command", payload)
|
|
}
|