c129cb99fc
Single Go binary serving the API and an embedded keyboard-first UI for promoting fafflix titles into the cheeztv kids tree via hardlinks. - internal/library: hardlink sync, idempotent re-runs, drift reporting, strict single-path-element name validation as the traversal guard - internal/arr: minimal sonarr/radarr v3 client with a 60s list cache and a key-brokered poster proxy - internal/auth: server-side Authentik group enforcement on every route - internal/server: library JSON API, art proxy, health probes, SPA - ui: two-tile landing page, fuzzy-filtered title list, detail panel - Makefile, Dockerfile, .woodpecker pipelines, pre-commit config
217 lines
5.7 KiB
Go
217 lines
5.7 KiB
Go
// Package arr is a minimal typed client for the sonarr/radarr v3 APIs, covering
|
|
// only what mediamark needs: the series/movie list (for titles, artwork ids and
|
|
// metadata) and a poster proxy.
|
|
//
|
|
// The api key is read from a mounted secret on every call so a Vault Secrets
|
|
// Operator rotation is picked up without a restart, and it is never echoed into
|
|
// a response or an error string.
|
|
package arr
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// maxListBytes caps a list response; the estate's libraries are a few MB of
|
|
// JSON at most and an unbounded read from a compromised upstream is a DoS.
|
|
const maxListBytes = 32 << 20
|
|
|
|
// maxArtBytes caps a proxied poster.
|
|
const maxArtBytes = 16 << 20
|
|
|
|
// ErrNoKey is returned when no api key is available for the app.
|
|
var ErrNoKey = errors.New("no api key available")
|
|
|
|
// Kind identifies which *arr an entry came from.
|
|
type Kind string
|
|
|
|
// The supported *arr kinds.
|
|
const (
|
|
KindSonarr Kind = "sonarr"
|
|
KindRadarr Kind = "radarr"
|
|
)
|
|
|
|
// Entry is the subset of a series/movie record mediamark shows.
|
|
type Entry struct {
|
|
ID int `json:"id"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year"`
|
|
Overview string `json:"overview"`
|
|
Status string `json:"status"`
|
|
// Path is the *arr's on-disk directory; its basename matches the library
|
|
// directory name.
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// Dir returns the library directory name for the entry.
|
|
func (e Entry) Dir() string {
|
|
if e.Path == "" {
|
|
return ""
|
|
}
|
|
return path.Base(strings.TrimRight(strings.ReplaceAll(e.Path, `\`, "/"), "/"))
|
|
}
|
|
|
|
// KeyFunc returns the api key for an app name ("sonarr"/"radarr").
|
|
type KeyFunc func(app string) (string, bool)
|
|
|
|
// Client talks to one *arr instance.
|
|
type Client struct {
|
|
kind Kind
|
|
baseURL string
|
|
key KeyFunc
|
|
http *http.Client
|
|
|
|
mu sync.Mutex
|
|
cached []Entry
|
|
cachedAt time.Time
|
|
ttl time.Duration
|
|
}
|
|
|
|
// New builds a Client for baseURL (which already includes the instance UrlBase).
|
|
func New(kind Kind, baseURL string, key KeyFunc) *Client {
|
|
return &Client{
|
|
kind: kind,
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
key: key,
|
|
http: &http.Client{Timeout: 30 * time.Second},
|
|
ttl: 60 * time.Second,
|
|
}
|
|
}
|
|
|
|
// SetCacheTTL overrides the list cache lifetime (tests use a zero TTL).
|
|
func (c *Client) SetCacheTTL(d time.Duration) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.ttl = d
|
|
}
|
|
|
|
// listPath is the collection endpoint for the kind.
|
|
func (c *Client) listPath() string {
|
|
if c.kind == KindSonarr {
|
|
return "/api/v3/series"
|
|
}
|
|
return "/api/v3/movie"
|
|
}
|
|
|
|
// List returns the instance's entries, served from a short-lived cache so list
|
|
// pages stay fast. A failed refresh does not poison the cache.
|
|
func (c *Client) List(ctx context.Context) ([]Entry, error) {
|
|
c.mu.Lock()
|
|
if c.cached != nil && c.ttl > 0 && time.Since(c.cachedAt) < c.ttl {
|
|
out := c.cached
|
|
c.mu.Unlock()
|
|
return out, nil
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
entries, err := c.fetchList(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.mu.Lock()
|
|
c.cached, c.cachedAt = entries, time.Now()
|
|
c.mu.Unlock()
|
|
return entries, nil
|
|
}
|
|
|
|
func (c *Client) fetchList(ctx context.Context) ([]Entry, error) {
|
|
resp, err := c.do(ctx, c.listPath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("%s list: unexpected status %d", c.kind, resp.StatusCode)
|
|
}
|
|
out := []Entry{}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, maxListBytes)).Decode(&out); err != nil {
|
|
return nil, fmt.Errorf("%s list: decode: %w", c.kind, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Art is a proxied poster response the caller must close.
|
|
type Art struct {
|
|
Body io.ReadCloser
|
|
ContentType string
|
|
}
|
|
|
|
// Poster streams the 250px poster for an entry id.
|
|
func (c *Client) Poster(ctx context.Context, id int) (*Art, error) {
|
|
resp, err := c.do(ctx, "/api/v3/mediacover/"+strconv.Itoa(id)+"/poster-250.jpg")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
_ = resp.Body.Close()
|
|
return nil, fmt.Errorf("%s poster %d: unexpected status %d", c.kind, id, resp.StatusCode)
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
if ct == "" {
|
|
ct = "image/jpeg"
|
|
}
|
|
return &Art{
|
|
Body: struct {
|
|
io.Reader
|
|
io.Closer
|
|
}{io.LimitReader(resp.Body, maxArtBytes), resp.Body},
|
|
ContentType: ct,
|
|
}, nil
|
|
}
|
|
|
|
// do issues an authenticated GET. Errors deliberately name only the app and
|
|
// path so a key can never reach a log line or an HTTP body.
|
|
func (c *Client) do(ctx context.Context, p string) (*http.Response, error) {
|
|
key, ok := c.key(string(c.kind))
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s: %w", c.kind, ErrNoKey)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+p, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s %s: build request", c.kind, p)
|
|
}
|
|
req.Header.Set("X-Api-Key", key)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s %s: request failed", c.kind, p)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// FileKeys reads *arr api keys from a mounted secret directory, re-reading on
|
|
// every call so rotations land without a restart. ARR_<APP>_APIKEY overrides it
|
|
// for local development.
|
|
func FileKeys(dir string) KeyFunc {
|
|
return func(app string) (string, bool) {
|
|
env := "ARR_" + strings.ToUpper(app) + "_APIKEY"
|
|
if v := strings.TrimSpace(os.Getenv(env)); v != "" {
|
|
return v, true
|
|
}
|
|
if dir == "" {
|
|
return "", false
|
|
}
|
|
// app is a fixed internal constant, never user input, but keep the
|
|
// read confined to the directory anyway.
|
|
if strings.ContainsAny(app, `/\`) {
|
|
return "", false
|
|
}
|
|
b, err := os.ReadFile(path.Join(dir, app))
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
key := strings.TrimSpace(string(b))
|
|
return key, key != ""
|
|
}
|
|
}
|