- matrix: drop cryptohelper LoginAs so device.json creds aren't invalidated on every restart by Init's re-login - matrix: thread ctx through PostThreadedReply; bot dispatcher derives per-command ctx from app lifecycle so SIGTERM cancels in-flight work - matrix: isTokenValid verifies /whoami user_id matches configured one - matrix: loadDevice distinguishes missing file from corrupt parse; refuse to silently overwrite a corrupt device.json - matrix: Start checks Syncer type assertion and returns error - arr: parseLookup skips empty-title items and extracts existing id - bot: skip Add when Result.Exists; reply "already in library"
152 lines
4.3 KiB
Go
152 lines
4.3 KiB
Go
// Package arr provides clients for Radarr, Sonarr, and Lidarr.
|
|
//
|
|
// Each client exposes Search to look up candidates by free-text query and
|
|
// Add to enqueue the first/chosen candidate for download. Lookup responses
|
|
// are preserved as raw JSON inside Result so Add can replay the entity back
|
|
// to the *arr server with the configured profile and root folder layered on.
|
|
package arr
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Result is a single candidate returned by an *arr lookup. Title and Year
|
|
// are extracted for display; raw is the untouched JSON object used to build
|
|
// the Add payload. Exists is true when the *arr server already has this item
|
|
// in its library (lookup responses set "id" to a non-zero value in that case),
|
|
// so the bot can skip the Add and report 'already in library' instead of
|
|
// blindly re-POSTing.
|
|
type Result struct {
|
|
Title string
|
|
Year int
|
|
Exists bool
|
|
raw json.RawMessage
|
|
}
|
|
|
|
// Raw exposes the underlying lookup JSON; useful for tests and debugging.
|
|
func (r Result) Raw() json.RawMessage { return r.raw }
|
|
|
|
// Client is the common interface implemented by every *arr backend.
|
|
type Client interface {
|
|
Search(ctx context.Context, term string) ([]Result, error)
|
|
Add(ctx context.Context, r Result) error
|
|
}
|
|
|
|
type httpClient struct {
|
|
baseURL string
|
|
apiKey string
|
|
hc *http.Client
|
|
}
|
|
|
|
func newHTTP(baseURL, apiKey string) *httpClient {
|
|
return &httpClient{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
apiKey: apiKey,
|
|
hc: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (c *httpClient) get(ctx context.Context, path string, query map[string]string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(query) > 0 {
|
|
q := req.URL.Query()
|
|
for k, v := range query {
|
|
q.Set(k, v)
|
|
}
|
|
req.URL.RawQuery = q.Encode()
|
|
}
|
|
return c.do(req)
|
|
}
|
|
|
|
func (c *httpClient) post(ctx context.Context, path string, body []byte) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return c.do(req)
|
|
}
|
|
|
|
func (c *httpClient) do(req *http.Request) ([]byte, error) {
|
|
req.Header.Set("X-Api-Key", c.apiKey)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.hc.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("%s %s: %d: %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// parseLookup decodes a lookup response into Results, pulling Title/Year out
|
|
// of each item while keeping the original JSON object intact.
|
|
func parseLookup(body []byte, titleField string) ([]Result, error) {
|
|
var items []json.RawMessage
|
|
if err := json.Unmarshal(body, &items); err != nil {
|
|
return nil, fmt.Errorf("decode lookup: %w", err)
|
|
}
|
|
out := make([]Result, 0, len(items))
|
|
for _, item := range items {
|
|
var meta map[string]json.RawMessage
|
|
if err := json.Unmarshal(item, &meta); err != nil {
|
|
continue
|
|
}
|
|
var title string
|
|
if raw, ok := meta[titleField]; ok && len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &title)
|
|
}
|
|
if title == "" {
|
|
// Skip items we can't display. The Add path would still POST the
|
|
// raw JSON, but the user reply would read "Added to Radarr:" with
|
|
// no title — worse than dropping the candidate.
|
|
continue
|
|
}
|
|
var year int
|
|
if raw, ok := meta["year"]; ok && len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &year)
|
|
}
|
|
var existingID int
|
|
if raw, ok := meta["id"]; ok && len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &existingID)
|
|
}
|
|
out = append(out, Result{
|
|
Title: title,
|
|
Year: year,
|
|
Exists: existingID > 0,
|
|
raw: item,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// buildAddBody decodes the raw lookup item into a generic map, overlays the
|
|
// provided fields, and re-marshals. Each *arr backend supplies the fields
|
|
// specific to its add endpoint.
|
|
func buildAddBody(raw json.RawMessage, overlay map[string]any) ([]byte, error) {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return nil, fmt.Errorf("decode lookup item: %w", err)
|
|
}
|
|
for k, v := range overlay {
|
|
m[k] = v
|
|
}
|
|
return json.Marshal(m)
|
|
}
|