coda/internal/lidarr/client.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
}