Files
silo-server/internal/requests/arrclient/client.go
Silo Server Migration 5b8aa9f34c feat(requests): add media detail page with TMDB metadata
- Add GetMediaDetail TMDB client returning normalized detail with cast, crew, recommendations, and certifications
- Add /api/requests/detail/{media_type}/{tmdb_id} endpoint overlaying availability and request state
- Add RequestDetail page and link poster cards to it
- Treat empty/truncated Radarr/Sonarr POST responses as accepted; drop pre-submit existence lookups
2026-05-24 17:03:29 -04:00

121 lines
2.8 KiB
Go

package arrclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const maxResponseBody = 1 << 20
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
}
type HTTPError struct {
StatusCode int
Body string
}
type DecodeError struct {
StatusCode int
Err error
}
func (e HTTPError) Error() string {
if e.Body == "" {
return fmt.Sprintf("arr: HTTP %d", e.StatusCode)
}
return fmt.Sprintf("arr: HTTP %d: %s", e.StatusCode, e.Body)
}
func (e DecodeError) Error() string {
return fmt.Sprintf("arr: decode response: %v", e.Err)
}
func (e DecodeError) Unwrap() error {
return e.Err
}
func IsEmptyOrTruncatedDecodeError(err error) bool {
var decodeErr DecodeError
if !errors.As(err, &decodeErr) {
return false
}
return errors.Is(decodeErr.Err, io.EOF) || errors.Is(decodeErr.Err, io.ErrUnexpectedEOF)
}
func New(baseURL, apiKey string, httpClient *http.Client) *Client {
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
return &Client{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
apiKey: strings.TrimSpace(apiKey),
httpClient: httpClient,
}
}
func (c *Client) GetJSON(ctx context.Context, path string, dest any) error {
return c.DoJSON(ctx, http.MethodGet, path, nil, dest)
}
func (c *Client) PostJSON(ctx context.Context, path string, body, dest any) error {
return c.DoJSON(ctx, http.MethodPost, path, body, dest)
}
func (c *Client) DoJSON(ctx context.Context, method, path string, body, dest any) error {
if c.baseURL == "" {
return fmt.Errorf("arr: base url is required")
}
if c.apiKey == "" {
return fmt.Errorf("arr: api key is required")
}
var reader io.Reader
if body != nil {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return fmt.Errorf("arr: encode request: %w", err)
}
reader = &buf
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("arr: create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Api-Key", c.apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("arr: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
return HTTPError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
if dest == nil || resp.StatusCode == http.StatusNoContent {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
return nil
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBody)).Decode(dest); err != nil {
return DecodeError{StatusCode: resp.StatusCode, Err: err}
}
return nil
}