Add the initial mediamark app
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

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
This commit is contained in:
2026-08-29 21:27:09 +10:00
parent 3b63e8a7f1
commit c129cb99fc
26 changed files with 3456 additions and 1 deletions
+216
View File
@@ -0,0 +1,216 @@
// 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 != ""
}
}
+208
View File
@@ -0,0 +1,208 @@
package arr
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
const testKey = "s3cr3t-api-key-value"
func staticKey(string) (string, bool) { return testKey, true }
func TestListSonarrAndRadarrHitTheRightEndpoint(t *testing.T) {
cases := []struct {
kind Kind
path string
body string
}{
{KindSonarr, "/base/api/v3/series", `[{"id":7,"title":"Bluey","year":2018,"overview":"dogs","status":"continuing","path":"/media/fafflix/tvseries/Bluey"}]`},
{KindRadarr, "/base/api/v3/movie", `[{"id":9,"title":"Nemo","year":2003,"overview":"fish","status":"released","path":"/media/fafflix/movies/Finding Nemo (2003)"}]`},
}
for _, c := range cases {
t.Run(string(c.kind), func(t *testing.T) {
var gotPath, gotKey string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotKey = r.URL.Path, r.Header.Get("X-Api-Key")
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, c.body)
}))
defer srv.Close()
cl := New(c.kind, srv.URL+"/base", staticKey)
entries, err := cl.List(context.Background())
if err != nil {
t.Fatal(err)
}
if gotPath != c.path {
t.Errorf("path = %q, want %q", gotPath, c.path)
}
if gotKey != testKey {
t.Errorf("X-Api-Key = %q", gotKey)
}
if len(entries) != 1 || entries[0].Title == "" || entries[0].Year == 0 {
t.Fatalf("entries = %+v", entries)
}
})
}
}
func TestEntryDirMatchesLibraryDirectory(t *testing.T) {
cases := []struct{ path, want string }{
{"/media/fafflix/movies/Finding Nemo (2003)", "Finding Nemo (2003)"},
{"/media/fafflix/tvseries/Bluey/", "Bluey"},
{`C:\media\movies\Cars`, "Cars"},
{"", ""},
}
for _, c := range cases {
if got := (Entry{Path: c.path}).Dir(); got != c.want {
t.Errorf("Dir(%q) = %q, want %q", c.path, got, c.want)
}
}
}
func TestListCachesAndExpires(t *testing.T) {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = io.WriteString(w, `[{"id":1,"title":"A","path":"/x/A"}]`)
}))
defer srv.Close()
cl := New(KindSonarr, srv.URL, staticKey)
for i := 0; i < 3; i++ {
if _, err := cl.List(context.Background()); err != nil {
t.Fatal(err)
}
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("upstream calls = %d, want 1 (cached)", got)
}
cl.SetCacheTTL(time.Nanosecond)
time.Sleep(time.Millisecond)
if _, err := cl.List(context.Background()); err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("upstream calls after expiry = %d, want 2", got)
}
}
func TestListErrorsNeverLeakTheKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Echo the key back the way a chatty upstream error page might.
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, "bad key "+testKey)
}))
defer srv.Close()
cl := New(KindSonarr, srv.URL, staticKey)
_, err := cl.List(context.Background())
if err == nil {
t.Fatal("List succeeded on a 401")
}
if strings.Contains(err.Error(), testKey) {
t.Fatalf("error leaked the api key: %v", err)
}
}
func TestNoKeyFailsClosed(t *testing.T) {
cl := New(KindSonarr, "http://unused.invalid", func(string) (string, bool) { return "", false })
if _, err := cl.List(context.Background()); !errors.Is(err, ErrNoKey) {
t.Fatalf("List = %v, want ErrNoKey", err)
}
if _, err := cl.Poster(context.Background(), 1); !errors.Is(err, ErrNoKey) {
t.Fatalf("Poster = %v, want ErrNoKey", err)
}
}
func TestPosterStreamsWithContentType(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/mediacover/42/poster-250.jpg" {
t.Errorf("poster path = %q", r.URL.Path)
}
if r.Header.Get("X-Api-Key") != testKey {
t.Error("poster request missing the api key")
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte{0x89, 'P', 'N', 'G'})
}))
defer srv.Close()
cl := New(KindRadarr, srv.URL, staticKey)
art, err := cl.Poster(context.Background(), 42)
if err != nil {
t.Fatal(err)
}
defer func() { _ = art.Body.Close() }()
if art.ContentType != "image/png" {
t.Errorf("ContentType = %q", art.ContentType)
}
b, err := io.ReadAll(art.Body)
if err != nil || len(b) != 4 {
t.Fatalf("body = %v %v", b, err)
}
}
func TestPosterMissingIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "not found "+testKey, http.StatusNotFound)
}))
defer srv.Close()
cl := New(KindSonarr, srv.URL, staticKey)
_, err := cl.Poster(context.Background(), 1)
if err == nil {
t.Fatal("Poster succeeded on a 404")
}
if strings.Contains(err.Error(), testKey) {
t.Fatalf("error leaked the api key: %v", err)
}
}
func TestFileKeysReReadsAfterRotation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sonarr")
if err := os.WriteFile(path, []byte("first\n"), 0o600); err != nil {
t.Fatal(err)
}
kf := FileKeys(dir)
if k, ok := kf("sonarr"); !ok || k != "first" {
t.Fatalf("key = %q %v", k, ok)
}
if err := os.WriteFile(path, []byte(" second "), 0o600); err != nil {
t.Fatal(err)
}
if k, ok := kf("sonarr"); !ok || k != "second" {
t.Fatalf("rotated key = %q %v, want trimmed second", k, ok)
}
if _, ok := kf("radarr"); ok {
t.Fatal("missing key file reported ok")
}
if err := os.WriteFile(filepath.Join(dir, "empty"), []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := kf("empty"); ok {
t.Fatal("blank key file reported ok")
}
if _, ok := kf("../etc/passwd"); ok {
t.Fatal("traversal key name reported ok")
}
}
func TestFileKeysEnvOverride(t *testing.T) {
t.Setenv("ARR_SONARR_APIKEY", "from-env")
kf := FileKeys(t.TempDir())
if k, ok := kf("sonarr"); !ok || k != "from-env" {
t.Fatalf("key = %q %v, want from-env", k, ok)
}
}