130 lines
2.7 KiB
Go
130 lines
2.7 KiB
Go
package lidarr
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"coda/internal/config"
|
|
)
|
|
|
|
const (
|
|
commandName = "RescanFolders"
|
|
maxRetries = 3
|
|
baseBackoff = 1 * time.Second
|
|
requestTimeout = 30 * time.Second
|
|
maxResponseBytes = 4096
|
|
)
|
|
|
|
type command struct {
|
|
Name string `json:"name"`
|
|
Folders []string `json:"folders,omitempty"`
|
|
}
|
|
|
|
type Client struct {
|
|
cfg *config.Service
|
|
hc *http.Client
|
|
}
|
|
|
|
func New(cfg *config.Service) *Client {
|
|
return &Client{
|
|
cfg: cfg,
|
|
hc: &http.Client{Timeout: requestTimeout},
|
|
}
|
|
}
|
|
|
|
func (c *Client) Rescan(ctx context.Context, jobPath string) error {
|
|
cfg := c.cfg.Get()
|
|
|
|
if cfg.DryRun {
|
|
slog.Info("dry run: would call Lidarr rescan", "path", jobPath)
|
|
return nil
|
|
}
|
|
|
|
if cfg.LidarrURL == "" || cfg.LidarrAPIKey == "" {
|
|
slog.Warn("Lidarr URL or API key not configured, skipping rescan",
|
|
"lidarr_url", cfg.LidarrURL,
|
|
"has_api_key", cfg.LidarrAPIKey != "",
|
|
)
|
|
return nil
|
|
}
|
|
|
|
cmd := command{Name: commandName, Folders: []string{jobPath}}
|
|
body, err := json.Marshal(cmd)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal command: %w", err)
|
|
}
|
|
|
|
url := cfg.LidarrURL + "/api/v1/command"
|
|
|
|
var lastErr error
|
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
backoff := baseBackoff * time.Duration(1<<(attempt-1))
|
|
slog.Warn("retrying Lidarr rescan", "attempt", attempt+1, "backoff", backoff)
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
}
|
|
|
|
lastErr = c.doRequest(ctx, url, cfg.LidarrAPIKey, body)
|
|
if lastErr == nil {
|
|
return nil
|
|
}
|
|
|
|
if !isRetryable(lastErr) {
|
|
return lastErr
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("rescan failed after %d attempts: %w", maxRetries, lastErr)
|
|
}
|
|
|
|
func (c *Client) doRequest(ctx context.Context, url, apiKey string, body []byte) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("X-Api-Key", apiKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.hc.Do(req)
|
|
if err != nil {
|
|
return &retryableError{msg: fmt.Sprintf("http request: %v", err)}
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
return nil
|
|
}
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
|
|
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
|
return fmt.Errorf("lidarr returned %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
return &retryableError{msg: fmt.Sprintf("lidarr returned %d: %s", resp.StatusCode, string(respBody))}
|
|
}
|
|
|
|
type retryableError struct {
|
|
msg string
|
|
}
|
|
|
|
func (e *retryableError) Error() string {
|
|
return e.msg
|
|
}
|
|
|
|
func isRetryable(err error) bool {
|
|
_, ok := err.(*retryableError)
|
|
return ok
|
|
}
|