Files
mediamark/internal/arr/arr.go
T
unkin-agent c129cb99fc
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Add the initial mediamark app
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
2026-08-29 21:27:09 +10:00

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 != ""
}
}