From c129cb99fc0c5ff63e9524f23c041ceb8a8c3aa8 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 29 Aug 2026 21:27:09 +1000 Subject: [PATCH] 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 --- .gitignore | 2 + .pre-commit-config.yaml | 27 ++ .woodpecker/build.yaml | 23 ++ .woodpecker/docker.yaml | 29 +++ .woodpecker/pre-commit.yaml | 19 ++ .woodpecker/test.yaml | 18 ++ Dockerfile | 21 ++ Makefile | 71 ++++++ README.md | 113 ++++++++- cmd/mediamark/main.go | 68 +++++ go.mod | 3 + internal/arr/arr.go | 216 ++++++++++++++++ internal/arr/arr_test.go | 208 +++++++++++++++ internal/auth/auth.go | 55 ++++ internal/auth/auth_test.go | 91 +++++++ internal/config/config.go | 83 ++++++ internal/config/config_test.go | 87 +++++++ internal/library/library.go | 367 +++++++++++++++++++++++++++ internal/library/library_test.go | 289 +++++++++++++++++++++ internal/server/server.go | 261 +++++++++++++++++++ internal/server/server_test.go | 364 ++++++++++++++++++++++++++ ui/embed.go | 19 ++ ui/static/app.css | 286 +++++++++++++++++++++ ui/static/app.js | 422 +++++++++++++++++++++++++++++++ ui/static/bg-space-pirate.svg | 223 ++++++++++++++++ ui/static/index.html | 92 +++++++ 26 files changed, 3456 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/docker.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/test.yaml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 cmd/mediamark/main.go create mode 100644 go.mod create mode 100644 internal/arr/arr.go create mode 100644 internal/arr/arr_test.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/library/library.go create mode 100644 internal/library/library_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 ui/embed.go create mode 100644 ui/static/app.css create mode 100644 ui/static/app.js create mode 100644 ui/static/bg-space-pirate.svg create mode 100644 ui/static/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7759987 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/dist/ +*.out diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4632fc2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-mod-tidy + + # mediamark has no root-level Go files (all under cmd/, internal/, ui/), so the + # dnephin go-vet hook (which runs `go vet` at the repo root) fails with + # "no Go files". Vet the whole module instead. + - repo: local + hooks: + - id: go-vet + name: go vet + entry: go vet ./... + language: system + types: [go] + pass_filenames: false diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..5ce330e --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,23 @@ +when: + - event: pull_request + +steps: + - name: docker-build + image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest + settings: + repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/mediamark + dockerfile: Dockerfile + dry_run: true + buildkit_config: | + [registry."artifactapi.k8s.syd1.au.unkin.net"] + ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"] + backend_options: + kubernetes: + serviceAccountName: mediamark-ci + resources: + requests: + memory: 1Gi + cpu: 1 + limits: + memory: 4Gi + cpu: 2 diff --git a/.woodpecker/docker.yaml b/.woodpecker/docker.yaml new file mode 100644 index 0000000..d06809c --- /dev/null +++ b/.woodpecker/docker.yaml @@ -0,0 +1,29 @@ +when: + - event: tag + ref: refs/tags/v* + +steps: + - name: docker + image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest + settings: + registry: artifactapi.k8s.syd1.au.unkin.net + repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/mediamark + dockerfile: Dockerfile + build_args: + VERSION: ${CI_COMMIT_TAG} + buildkit_config: | + [registry."artifactapi.k8s.syd1.au.unkin.net"] + ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"] + tags: + - ${CI_COMMIT_TAG} + - latest + backend_options: + kubernetes: + serviceAccountName: mediamark-ci + resources: + requests: + memory: 1Gi + cpu: 1 + limits: + memory: 4Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..99a6341 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,19 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: golang:1.25 + commands: + - test -z "$(gofmt -l .)" + - go vet ./... + backend_options: + kubernetes: + serviceAccountName: mediamark-ci + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..a38dfcc --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: test + image: golang:1.25 + commands: + - go test -race -count=1 ./... + backend_options: + kubernetes: + serviceAccountName: mediamark-ci + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..68c4365 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.25-alpine AS builder + +RUN apk add --no-cache git + +WORKDIR /build + +COPY go.mod ./ +RUN go mod download + +COPY . . + +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o mediamark ./cmd/mediamark + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=builder /build/mediamark /usr/local/bin/mediamark + +EXPOSE 8080 + +ENTRYPOINT ["mediamark"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5b3365d --- /dev/null +++ b/Makefile @@ -0,0 +1,71 @@ +DIST := dist +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +GOFLAGS := -ldflags="-s -w -X main.version=$(VERSION)" +OS ?= $(shell go env GOOS) +ARCH ?= $(shell go env GOARCH) + +REGISTRY := artifactapi.k8s.syd1.au.unkin.net/docker-internal + +# Shipped binaries; each has its own main package under cmd/. +BINARIES := mediamark + +.PHONY: all build test vet fmt lint clean images patch minor major _tag pre-commit run + +all: build + +# Mirror the .woodpecker/pre-commit.yaml checks locally. +pre-commit: + test -z "$$(gofmt -l .)" + go vet ./... + +build: + @for b in $(BINARIES); do \ + echo "building $$b"; \ + CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build $(GOFLAGS) -o $(DIST)/$$b ./cmd/$$b || exit 1; \ + done + +test: + go test -race -count=1 ./... + +vet: + go vet ./... + +fmt: + gofmt -w . + +lint: + golangci-lint run ./... + +clean: + rm -rf $(DIST) + +# Local convenience: build the container image. +images: + docker build --build-arg VERSION=$(VERSION) -t $(REGISTRY)/mediamark:$(VERSION) . + +# Local convenience: serve against a scratch media tree with no *arr backends. +run: build + MEDIAMARK_MEDIA_ROOT=$(PWD)/testdata/media $(DIST)/mediamark + +# Bump helpers — read the latest semver tag and create the next one. CI builds +# and pushes the image on the resulting v* tag. +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && $(MAKE) _tag TAG=$$NEW + +_tag: + git push origin $(TAG) diff --git a/README.md b/README.md index d43f78d..788bb6d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,114 @@ # mediamark -Keyboard-centric web UI to mark media as cheeztv (kids) content via hardlinks; oauth2-proxy fronted, talks to sonarr/radarr APIs \ No newline at end of file +Keyboard-centric web UI to mark media as cheeztv (kids) content via hardlinks; +oauth2-proxy fronted, talks to sonarr/radarr APIs. + +A single Go binary carrying its own UI. It lists the titles in the `fafflix` +library and lets an authorized Authentik group promote any of them into the +`cheeztv` kids tree. Promotion is a recursive **hardlink** sync, so a marked +title consumes no extra space and unmarking can never destroy media. + +## How marking works + +The media root holds two parallel trees: + +``` +/ + fafflix/{movies,tvseries}//... source library (radarr / sonarr) + cheeztv/{movies,tvseries}/<title>/... kids tree (hardlinks into fafflix) +``` + +* **Mark** walks the source title and hardlinks every regular file into the + same-named directory under `cheeztv/`. It is idempotent: files already + pointing at the same inode are left alone, a kids-side file pointing at a + *different* inode is replaced, and symlinks/devices are skipped. Re-running it + after new episodes land syncs only the new files. +* **Unmark** removes the `cheeztv/` directory only. The source inode keeps its + link, so nothing is lost. +* A marked title whose source has files that are not linked across is reported + as `needsSync`, and the detail view offers a **Sync new files** button. + +Title names come from directory entries and are validated as a single clean path +element (no separators, no `..`, no absolute paths) before touching the disk. + +## Configuration + +All configuration is environment-only. Startup fails closed on an empty group +allow-list, a relative media root, or a non-HTTP *arr URL. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `MEDIAMARK_LISTEN` | `:8080` | HTTP listen address | +| `MEDIAMARK_MEDIA_ROOT` | `/media` | Root holding `fafflix/` and `cheeztv/` | +| `MEDIAMARK_KEYS_DIR` | `/etc/mediamark/keys` | Directory of `sonarr`/`radarr` API key files, re-read per use so VSO rotations land without a restart | +| `MEDIAMARK_SONARR_URL` | `http://sonarr.arrstack.svc.cluster.local:8989/3aa168/sonarr` | Sonarr base URL, including its `UrlBase` | +| `MEDIAMARK_RADARR_URL` | `http://radarr.arrstack.svc.cluster.local:7878/3aa168/radarr` | Radarr base URL, including its `UrlBase` | +| `MEDIAMARK_GROUPS_HEADER` | `X-Forwarded-Groups` | oauth2-proxy header carrying Authentik groups | +| `MEDIAMARK_ALLOWED_GROUPS` | `akP-mediamark-user` | Comma/space separated group allow-list; must be non-empty | +| `ARR_SONARR_APIKEY` / `ARR_RADARR_APIKEY` | _(unset)_ | Development escape hatch, overrides the key files | + +oauth2-proxy fronts the app, but group membership is re-checked server-side on +every API call *and* on the page load itself, so an unauthorized user gets a 403 +rather than an empty shell. `/livez` and `/readyz` are ungated for the kubelet; +`/readyz` fails while the media root is not visible. + +## HTTP API + +| Route | Purpose | +| --- | --- | +| `GET /api/library/{section}` | Titles in `movies` or `tvseries` with marked/drift state, size, and *arr metadata | +| `POST /api/library/{section}/{name}/mark` | Hardlink-sync the title into `cheeztv` (idempotent) | +| `POST /api/library/{section}/{name}/unmark` | Remove the title's `cheeztv` directory (idempotent) | +| `GET /api/art/{section}/{id}` | Poster proxy; the *arr API key is injected server-side and never exposed | +| `GET /livez`, `GET /readyz` | Health probes | + +Sonarr/radarr list responses are cached in memory for ~60s so list pages stay +fast; a failed *arr lookup degrades to an art-less listing rather than an error. + +## Keyboard map + +| Where | Key | Action | +| --- | --- | --- | +| Landing | `m` / `t` | Open Movies / TV Series | +| Landing | `←` `→` `↑` `↓` | Move between tiles | +| Landing | `Enter` | Open the selected tile | +| Section | _any character_ | Filter (fuzzy, fzf-style subsequence scoring) | +| Section | `↑` `↓` | Move the selection (scrolls into view) | +| Section | `Enter` | Open the title detail | +| Section | `m` | Toggle cheeztv on the selection (after arrowing off the search box) | +| Section | `Esc` | Clear the search, then return to the landing page | +| Detail | `m` | Toggle cheeztv | +| Detail | `Esc` | Back to the section list | + +The search box is autofocused, so typing always filters. Arrowing moves focus to +the list, which is what frees `m` to act as a shortcut; typing any other +character hands focus straight back to the search box. + +## Development + +```sh +make build # dist/mediamark +make test # go test -race -count=1 ./... +make pre-commit # gofmt + go vet, as CI runs them +make lint # golangci-lint +uvx pre-commit run --all-files +``` + +To run against a scratch library with no *arr backends: + +```sh +mkdir -p /tmp/mm/{fafflix,cheeztv}/{movies,tvseries} +mkdir -p "/tmp/mm/fafflix/movies/Some Film (2019)" +MEDIAMARK_MEDIA_ROOT=/tmp/mm ./dist/mediamark +``` + +Then send the group header yourself, since there is no oauth2-proxy in front: + +```sh +curl -H 'X-Forwarded-Groups: akP-mediamark-user' localhost:8080/api/library/movies +``` + +## Release + +Tagging `v*` (via `make patch|minor|major`) builds and pushes +`artifactapi.k8s.syd1.au.unkin.net/docker-internal/mediamark:<tag>`. diff --git a/cmd/mediamark/main.go b/cmd/mediamark/main.go new file mode 100644 index 0000000..716bca2 --- /dev/null +++ b/cmd/mediamark/main.go @@ -0,0 +1,68 @@ +// Command mediamark serves the media-marking web app: a single binary carrying +// its own UI that hardlinks titles from the fafflix library into the cheeztv +// kids tree. +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.unkin.net/unkin/mediamark/internal/arr" + "git.unkin.net/unkin/mediamark/internal/config" + "git.unkin.net/unkin/mediamark/internal/library" + "git.unkin.net/unkin/mediamark/internal/server" + "git.unkin.net/unkin/mediamark/ui" +) + +var version = "dev" + +func main() { + log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + cfg, err := config.Load() + if err != nil { + log.Error("config", "err", err) + os.Exit(1) + } + + keys := arr.FileKeys(cfg.KeysDir) + lib := library.New(cfg.MediaRoot) + arrs := map[library.Section]*arr.Client{ + library.SectionTVSeries: arr.New(arr.KindSonarr, cfg.SonarrURL, keys), + library.SectionMovies: arr.New(arr.KindRadarr, cfg.RadarrURL, keys), + } + + srv := &http.Server{ + Addr: cfg.Listen, + Handler: server.New(cfg, lib, arrs, ui.Assets(), log).Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + log.Info("mediamark listening", + "addr", cfg.Listen, "version", version, + "mediaRoot", cfg.MediaRoot, "allowedGroups", cfg.AllowedGroups) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Error("serve", "err", err) + os.Exit(1) + } + }() + + <-ctx.Done() + log.Info("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Error("shutdown", "err", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..54d84ef --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.unkin.net/unkin/mediamark + +go 1.25 diff --git a/internal/arr/arr.go b/internal/arr/arr.go new file mode 100644 index 0000000..368c980 --- /dev/null +++ b/internal/arr/arr.go @@ -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 != "" + } +} diff --git a/internal/arr/arr_test.go b/internal/arr/arr_test.go new file mode 100644 index 0000000..01c65cf --- /dev/null +++ b/internal/arr/arr_test.go @@ -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) + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..6a89732 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,55 @@ +// Package auth enforces Authentik group membership from the oauth2-proxy +// identity header. oauth2-proxy already gates the route, but mediamark mutates +// the on-disk library, so it re-checks the group server-side rather than +// trusting the front door alone. +package auth + +import ( + "net/http" + + "git.unkin.net/unkin/mediamark/internal/config" +) + +// Middleware rejects requests whose group header carries none of the allowed +// groups. header is the request header to read; allowed must be non-empty. +type Middleware struct { + header string + allowed map[string]bool +} + +// New builds a Middleware. An empty allowed set denies everything, which is the +// correct fail-closed behaviour if config validation is ever bypassed. +func New(header string, allowed []string) *Middleware { + m := &Middleware{header: header, allowed: make(map[string]bool, len(allowed))} + for _, g := range allowed { + m.allowed[g] = true + } + return m +} + +// Permit reports whether the request carries an allowed group. +func (m *Middleware) Permit(r *http.Request) bool { + if len(m.allowed) == 0 { + return false + } + for _, v := range r.Header.Values(m.header) { + for _, g := range config.ParseGroups(v) { + if m.allowed[g] { + return true + } + } + } + return false +} + +// Wrap gates next behind Permit, answering 403 with a plain body that never +// echoes the submitted groups back to the caller. +func (m *Middleware) Wrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !m.Permit(r) { + http.Error(w, "forbidden: missing required group", http.StatusForbidden) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..d16cc3c --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,91 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPermit(t *testing.T) { + m := New("X-Forwarded-Groups", []string{"akP-mediamark-user", "akR-admins"}) + cases := []struct { + name string + values []string + want bool + }{ + {"no header", nil, false}, + {"empty header", []string{""}, false}, + {"exact match", []string{"akP-mediamark-user"}, true}, + {"comma list containing match", []string{"akP-other,akP-mediamark-user,akP-x"}, true}, + {"space separated", []string{"akP-other akR-admins"}, true}, + {"padded", []string{" akP-mediamark-user "}, true}, + {"repeated header lines", []string{"akP-nope", "akR-admins"}, true}, + {"unrelated groups only", []string{"akP-arrstack-kids,akP-nope"}, false}, + {"prefix lookalike", []string{"akP-mediamark-users"}, false}, + {"substring lookalike", []string{"xakP-mediamark-user"}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/api/library/movies", nil) + for _, v := range c.values { + r.Header.Add("X-Forwarded-Groups", v) + } + if got := m.Permit(r); got != c.want { + t.Fatalf("Permit = %v, want %v", got, c.want) + } + }) + } +} + +func TestEmptyAllowListDeniesEverything(t *testing.T) { + m := New("X-Forwarded-Groups", nil) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-Forwarded-Groups", "anything") + if m.Permit(r) { + t.Fatal("empty allow-list permitted a request") + } +} + +func TestWrapBlocksAndPasses(t *testing.T) { + m := New("X-Grp", []string{"good"}) + called := false + h := m.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + })) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/library/movies/x/mark", nil) + req.Header.Set("X-Grp", "bad,worse") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if called { + t.Fatal("handler ran despite a forbidden request") + } + // The rejection must not echo the submitted groups back. + if strings.Contains(rec.Body.String(), "worse") { + t.Fatalf("403 body echoed submitted groups: %q", rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/library/movies/x/mark", nil) + req.Header.Set("X-Grp", "good") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusTeapot || !called { + t.Fatalf("allowed request not passed through: status %d called %v", rec.Code, called) + } +} + +// The configured header is the only one trusted; a client-forged alternative +// must not grant access. +func TestOnlyConfiguredHeaderIsRead(t *testing.T) { + m := New("X-Auth-Request-Groups", []string{"good"}) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-Forwarded-Groups", "good") + if m.Permit(r) { + t.Fatal("a non-configured header granted access") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..e863ad2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,83 @@ +// Package config loads mediamark runtime configuration from the environment. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Config is the fully-resolved mediamark configuration. +type Config struct { + // Listen is the HTTP listen address, e.g. ":8080". + Listen string + // MediaRoot holds the fafflix/ (source) and cheeztv/ (kids) library trees. + MediaRoot string + // KeysDir holds per-app *arr api keys as files named "sonarr"/"radarr", + // projected by the Vault Secrets Operator and re-read on every use. + KeysDir string + // SonarrURL and RadarrURL include the *arr instance's UrlBase; the API + // lives at <url>/api/v3/... + SonarrURL string + RadarrURL string + // GroupsHeader is the oauth2-proxy header carrying Authentik group names. + GroupsHeader string + // AllowedGroups gates every page load and API call. Never empty. + AllowedGroups []string +} + +// Load resolves configuration from the environment, failing closed on an empty +// allow-list (an empty list would authorize nobody or, worse, be read as +// "anyone" by a future refactor). +func Load() (*Config, error) { + c := &Config{ + Listen: envOr("MEDIAMARK_LISTEN", ":8080"), + MediaRoot: envOr("MEDIAMARK_MEDIA_ROOT", "/media"), + KeysDir: envOr("MEDIAMARK_KEYS_DIR", "/etc/mediamark/keys"), + SonarrURL: envOr("MEDIAMARK_SONARR_URL", "http://sonarr.arrstack.svc.cluster.local:8989/3aa168/sonarr"), + RadarrURL: envOr("MEDIAMARK_RADARR_URL", "http://radarr.arrstack.svc.cluster.local:7878/3aa168/radarr"), + GroupsHeader: envOr("MEDIAMARK_GROUPS_HEADER", "X-Forwarded-Groups"), + AllowedGroups: ParseGroups(envOr("MEDIAMARK_ALLOWED_GROUPS", "akP-mediamark-user")), + } + + if len(c.AllowedGroups) == 0 { + return nil, fmt.Errorf("MEDIAMARK_ALLOWED_GROUPS must name at least one group") + } + if strings.TrimSpace(c.GroupsHeader) == "" { + return nil, fmt.Errorf("MEDIAMARK_GROUPS_HEADER must not be empty") + } + if !filepath.IsAbs(c.MediaRoot) { + return nil, fmt.Errorf("MEDIAMARK_MEDIA_ROOT %q must be an absolute path", c.MediaRoot) + } + c.MediaRoot = filepath.Clean(c.MediaRoot) + for _, u := range []struct{ name, val string }{{"MEDIAMARK_SONARR_URL", c.SonarrURL}, {"MEDIAMARK_RADARR_URL", c.RadarrURL}} { + if !strings.HasPrefix(u.val, "http://") && !strings.HasPrefix(u.val, "https://") { + return nil, fmt.Errorf("%s %q must be an http(s) URL", u.name, u.val) + } + } + return c, nil +} + +// ParseGroups splits a group list tolerating both comma and whitespace +// separation, dropping empties. oauth2-proxy emits comma-separated groups but +// deployments hand-write the allow-list. +func ParseGroups(s string) []string { + fields := strings.FieldsFunc(s, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ';' + }) + out := make([]string, 0, len(fields)) + for _, f := range fields { + if f = strings.TrimSpace(f); f != "" { + out = append(out, f) + } + } + return out +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5598315 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,87 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestParseGroups(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", []string{}}, + {"akP-mediamark-user", []string{"akP-mediamark-user"}}, + {"a,b,c", []string{"a", "b", "c"}}, + {" a , b ,, c ", []string{"a", "b", "c"}}, + {"a b\tc", []string{"a", "b", "c"}}, + {"a,\nb;c", []string{"a", "b", "c"}}, + {",,,", []string{}}, + } + for _, c := range cases { + if got := ParseGroups(c.in); !reflect.DeepEqual(got, c.want) { + t.Errorf("ParseGroups(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestLoadDefaults(t *testing.T) { + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Listen != ":8080" || cfg.MediaRoot != "/media" || cfg.KeysDir != "/etc/mediamark/keys" { + t.Fatalf("unexpected defaults: %+v", cfg) + } + if cfg.GroupsHeader != "X-Forwarded-Groups" { + t.Fatalf("GroupsHeader = %q", cfg.GroupsHeader) + } + if !reflect.DeepEqual(cfg.AllowedGroups, []string{"akP-mediamark-user"}) { + t.Fatalf("AllowedGroups = %v", cfg.AllowedGroups) + } +} + +func TestLoadFailsClosed(t *testing.T) { + cases := []struct { + name string + env map[string]string + }{ + {"blank allowed groups", map[string]string{"MEDIAMARK_ALLOWED_GROUPS": " , , "}}, + {"relative media root", map[string]string{"MEDIAMARK_MEDIA_ROOT": "media"}}, + {"non-http sonarr url", map[string]string{"MEDIAMARK_SONARR_URL": "sonarr:8989"}}, + {"non-http radarr url", map[string]string{"MEDIAMARK_RADARR_URL": "ftp://radarr"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + for k, v := range c.env { + t.Setenv(k, v) + } + if _, err := Load(); err == nil { + t.Fatal("Load succeeded, want error") + } + }) + } +} + +func TestLoadOverrides(t *testing.T) { + t.Setenv("MEDIAMARK_LISTEN", ":9999") + t.Setenv("MEDIAMARK_MEDIA_ROOT", "/srv/media/") + t.Setenv("MEDIAMARK_ALLOWED_GROUPS", "akP-mediamark-user, akR-admins") + t.Setenv("MEDIAMARK_GROUPS_HEADER", "X-Auth-Request-Groups") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.Listen != ":9999" { + t.Errorf("Listen = %q", cfg.Listen) + } + if cfg.MediaRoot != "/srv/media" { + t.Errorf("MediaRoot = %q, want cleaned /srv/media", cfg.MediaRoot) + } + if !reflect.DeepEqual(cfg.AllowedGroups, []string{"akP-mediamark-user", "akR-admins"}) { + t.Errorf("AllowedGroups = %v", cfg.AllowedGroups) + } + if cfg.GroupsHeader != "X-Auth-Request-Groups" { + t.Errorf("GroupsHeader = %q", cfg.GroupsHeader) + } +} diff --git a/internal/library/library.go b/internal/library/library.go new file mode 100644 index 0000000..0dec7d9 --- /dev/null +++ b/internal/library/library.go @@ -0,0 +1,367 @@ +// Package library models the two-tree media layout and the hardlink sync that +// marks a title as kids ("cheeztv") content. +// +// Layout under the media root: +// +// fafflix/{tvseries,movies}/<title>/... source library +// cheeztv/{tvseries,movies}/<title>/... kids tree (hardlinks into fafflix) +// +// Marking a title hardlinks every regular file across; unmarking removes only +// the kids-side directory, which never destroys data because the source inode +// keeps a link. +package library + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// Tree names, relative to the media root. +const ( + SourceTree = "fafflix" + KidsTree = "cheeztv" +) + +// Section is a library section directory shared by both trees. +type Section string + +// The supported sections. +const ( + SectionTVSeries Section = "tvseries" + SectionMovies Section = "movies" +) + +// Sections lists every valid section in display order. +var Sections = []Section{SectionMovies, SectionTVSeries} + +// ErrInvalidSection is returned for a section outside Sections. +var ErrInvalidSection = errors.New("invalid section") + +// ErrInvalidName is returned when a title name is not a single, clean path +// element — the guard against path traversal out of the library roots. +var ErrInvalidName = errors.New("invalid title name") + +// ErrNotFound is returned when a title has no source directory. +var ErrNotFound = errors.New("title not found") + +// ParseSection validates s against the known sections. +func ParseSection(s string) (Section, error) { + for _, k := range Sections { + if string(k) == s { + return k, nil + } + } + return "", fmt.Errorf("%w: %q", ErrInvalidSection, s) +} + +// ValidateName rejects anything that is not a single, clean path element: +// empties, dot entries, absolute paths, and any name containing a separator. +// This is the sole traversal guard between an HTTP path segment and the +// filesystem, so it is deliberately strict rather than sanitising. +func ValidateName(name string) error { + switch { + case name == "": + return fmt.Errorf("%w: empty", ErrInvalidName) + case name == "." || name == "..": + return fmt.Errorf("%w: dot entry", ErrInvalidName) + // A backslash is a legal byte in a Linux filename, but it is a separator on + // the platforms that write into these libraries; reject it rather than + // reason about which side produced the name. + case strings.ContainsRune(name, '/'), strings.ContainsRune(name, '\\'), strings.ContainsRune(name, os.PathSeparator): + return fmt.Errorf("%w: contains a path separator", ErrInvalidName) + case strings.ContainsRune(name, 0): + return fmt.Errorf("%w: contains NUL", ErrInvalidName) + case filepath.IsAbs(name): + return fmt.Errorf("%w: absolute path", ErrInvalidName) + case name != filepath.Clean(name): + return fmt.Errorf("%w: not a clean path element", ErrInvalidName) + } + return nil +} + +// Title is the reported state of one library title. +type Title struct { + Name string `json:"name"` + // Marked is true when the kids tree has a directory of the same name. + Marked bool `json:"marked"` + // NeedsSync is true for a marked title whose source holds regular files + // that are not linked into the kids tree (new episodes, upgrades). + NeedsSync bool `json:"needsSync"` + // SizeBytes and FileCount describe the source directory. + SizeBytes int64 `json:"sizeBytes"` + FileCount int `json:"fileCount"` + // UnlinkedFiles counts the source files missing from the kids tree; zero + // for an unmarked title. + UnlinkedFiles int `json:"unlinkedFiles"` +} + +// Library reads and mutates the two trees under Root. +type Library struct { + Root string +} + +// New returns a Library rooted at the (cleaned) media root. +func New(root string) *Library { return &Library{Root: filepath.Clean(root)} } + +// sourceRoot and kidsRoot are the per-section tree roots. +func (l *Library) sourceRoot(s Section) string { return filepath.Join(l.Root, SourceTree, string(s)) } +func (l *Library) kidsRoot(s Section) string { return filepath.Join(l.Root, KidsTree, string(s)) } + +// paths resolves the source and kids directories for a validated title, and +// re-verifies containment: ValidateName already makes escape impossible, but +// the check costs nothing and keeps the invariant local to the filesystem call. +func (l *Library) paths(section Section, name string) (src, kids string, err error) { + if _, err := ParseSection(string(section)); err != nil { + return "", "", err + } + if err := ValidateName(name); err != nil { + return "", "", err + } + src = filepath.Join(l.sourceRoot(section), name) + kids = filepath.Join(l.kidsRoot(section), name) + if !under(l.sourceRoot(section), src) || !under(l.kidsRoot(section), kids) { + return "", "", fmt.Errorf("%w: escapes the library root", ErrInvalidName) + } + return src, kids, nil +} + +// under reports whether path is root itself or lies beneath it. +func under(root, path string) bool { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} + +// Ready reports whether the media root is visible, backing /readyz. +func (l *Library) Ready() error { + fi, err := os.Stat(l.Root) + if err != nil { + return err + } + if !fi.IsDir() { + return fmt.Errorf("media root %q is not a directory", l.Root) + } + return nil +} + +// List reports every title in a section, sorted case-insensitively by name. +func (l *Library) List(section Section) ([]Title, error) { + if _, err := ParseSection(string(section)); err != nil { + return nil, err + } + entries, err := os.ReadDir(l.sourceRoot(section)) + if err != nil { + if os.IsNotExist(err) { + return []Title{}, nil + } + return nil, err + } + out := make([]Title, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() || ValidateName(e.Name()) != nil { + continue + } + t, err := l.Stat(section, e.Name()) + if err != nil { + continue + } + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { + li, lj := strings.ToLower(out[i].Name), strings.ToLower(out[j].Name) + if li == lj { + return out[i].Name < out[j].Name + } + return li < lj + }) + return out, nil +} + +// Stat reports one title's size, file count, marked state and link drift. +func (l *Library) Stat(section Section, name string) (Title, error) { + src, kids, err := l.paths(section, name) + if err != nil { + return Title{}, err + } + fi, err := os.Stat(src) + if err != nil { + if os.IsNotExist(err) { + return Title{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name) + } + return Title{}, err + } + if !fi.IsDir() { + return Title{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name) + } + + t := Title{Name: name} + if kfi, err := os.Stat(kids); err == nil && kfi.IsDir() { + t.Marked = true + } + + err = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + // A vanished file mid-walk is normal on a live library; skip it + // rather than failing the whole listing. + if os.IsNotExist(err) { + return nil + } + return err + } + if d.IsDir() || !d.Type().IsRegular() { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + t.FileCount++ + t.SizeBytes += info.Size() + if !t.Marked { + return nil + } + rel, err := filepath.Rel(src, p) + if err != nil { + return nil + } + if !sameFile(p, filepath.Join(kids, rel)) { + t.UnlinkedFiles++ + } + return nil + }) + if err != nil { + return Title{}, err + } + t.NeedsSync = t.Marked && t.UnlinkedFiles > 0 + return t, nil +} + +// sameFile reports whether both paths resolve to the same inode. +func sameFile(a, b string) bool { + ai, err := os.Lstat(a) + if err != nil { + return false + } + bi, err := os.Lstat(b) + if err != nil { + return false + } + return os.SameFile(ai, bi) +} + +// SyncResult reports what a Mark run changed. +type SyncResult struct { + Linked int `json:"linked"` + Replaced int `json:"replaced"` + Unchanged int `json:"unchanged"` + Skipped int `json:"skipped"` + Dirs int `json:"dirs"` +} + +// Mark hardlinks every regular file of a title into the kids tree, creating the +// directory skeleton as it goes. It is idempotent: files already linked to the +// same inode are left alone, a kids-side file pointing at a different inode is +// replaced, and symlinks/devices/sockets are skipped. Re-running after new +// episodes arrive syncs only the new files. +func (l *Library) Mark(section Section, name string) (SyncResult, error) { + src, kids, err := l.paths(section, name) + if err != nil { + return SyncResult{}, err + } + fi, err := os.Stat(src) + if err != nil || !fi.IsDir() { + return SyncResult{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name) + } + if err := os.MkdirAll(kids, 0o755); err != nil { + return SyncResult{}, err + } + + var res SyncResult + walkErr := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + if rel == "." { + return nil + } + dst := filepath.Join(kids, rel) + if !under(kids, dst) { + // Unreachable for a well-formed walk; belt-and-braces against a + // crafted name surviving the entry guards. + return fmt.Errorf("refusing to write outside the kids tree: %s", dst) + } + switch { + case d.IsDir(): + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + res.Dirs++ + return nil + case !d.Type().IsRegular(): + res.Skipped++ + return nil + } + return linkFile(p, dst, &res) + }) + if walkErr != nil { + return res, walkErr + } + return res, nil +} + +// linkFile hardlinks src to dst, replacing a stale link to a different inode. +func linkFile(src, dst string, res *SyncResult) error { + di, err := os.Lstat(dst) + switch { + case err == nil: + si, serr := os.Lstat(src) + if serr == nil && di.Mode().IsRegular() && os.SameFile(si, di) { + res.Unchanged++ + return nil + } + if err := os.Remove(dst); err != nil { + return err + } + if err := os.Link(src, dst); err != nil { + return err + } + res.Replaced++ + return nil + case !os.IsNotExist(err): + return err + } + if err := os.Link(src, dst); err != nil { + // A file that disappeared between the walk and the link is not fatal. + if os.IsNotExist(err) { + res.Skipped++ + return nil + } + return err + } + res.Linked++ + return nil +} + +// Unmark removes a title's kids-tree directory. Only hardlinks live there, so +// the source library is untouched. Removing an already-absent title succeeds. +func (l *Library) Unmark(section Section, name string) error { + _, kids, err := l.paths(section, name) + if err != nil { + return err + } + return os.RemoveAll(kids) +} diff --git a/internal/library/library_test.go b/internal/library/library_test.go new file mode 100644 index 0000000..5d0f6f6 --- /dev/null +++ b/internal/library/library_test.go @@ -0,0 +1,289 @@ +package library + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +// newLib builds a media root with the four tree/section dirs and returns it. +func newLib(t *testing.T) *Library { + t.Helper() + root := t.TempDir() + for _, tree := range []string{SourceTree, KidsTree} { + for _, s := range Sections { + if err := os.MkdirAll(filepath.Join(root, tree, string(s)), 0o755); err != nil { + t.Fatal(err) + } + } + } + return New(root) +} + +// writeSrc creates a source file with content, making parents as needed. +func writeSrc(t *testing.T, l *Library, s Section, rel, content string) string { + t.Helper() + p := filepath.Join(l.sourceRoot(s), rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestValidateNameRejectsTraversal(t *testing.T) { + bad := []string{ + "", ".", "..", "../etc", "..", "a/b", "/abs", "/etc/passwd", + "sub/../..", "./x", "x/", "a\x00b", "..\\evil", + } + for _, name := range bad { + if err := ValidateName(name); err == nil { + t.Errorf("ValidateName(%q) = nil, want error", name) + } else if !errors.Is(err, ErrInvalidName) { + t.Errorf("ValidateName(%q) error %v, want ErrInvalidName", name, err) + } + } + good := []string{"The Muppets", "Movie (2019)", "a.b.c", "...", "dot.dir", "Ünïcødé"} + for _, name := range good { + if err := ValidateName(name); err != nil { + t.Errorf("ValidateName(%q) = %v, want nil", name, err) + } + } +} + +func TestPathsRejectsTraversalAndKeepsContainment(t *testing.T) { + l := newLib(t) + if _, _, err := l.paths(SectionMovies, "../../etc"); err == nil { + t.Fatal("paths accepted a traversal name") + } + src, kids, err := l.paths(SectionMovies, "Nemo") + if err != nil { + t.Fatal(err) + } + if !under(l.sourceRoot(SectionMovies), src) || !under(l.kidsRoot(SectionMovies), kids) { + t.Fatalf("resolved paths escaped roots: %s %s", src, kids) + } +} + +// TestMarkOperationsCannotEscapeRoot proves a crafted name never touches a file +// outside the media root, even when the target already exists there. +func TestMarkOperationsCannotEscapeRoot(t *testing.T) { + l := newLib(t) + outside := filepath.Join(t.TempDir(), "victim") + if err := os.MkdirAll(outside, 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"../../../" + filepath.Base(outside), "..", "../movies"} { + if _, err := l.Mark(SectionMovies, name); !errors.Is(err, ErrInvalidName) { + t.Errorf("Mark(%q) error = %v, want ErrInvalidName", name, err) + } + if err := l.Unmark(SectionMovies, name); !errors.Is(err, ErrInvalidName) { + t.Errorf("Unmark(%q) error = %v, want ErrInvalidName", name, err) + } + } + if _, err := os.Stat(outside); err != nil { + t.Fatalf("victim directory was disturbed: %v", err) + } +} + +func TestMarkHardlinksTreeAndIsIdempotent(t *testing.T) { + l := newLib(t) + writeSrc(t, l, SectionTVSeries, "Bluey/Season 01/e01.mkv", "one") + writeSrc(t, l, SectionTVSeries, "Bluey/Season 01/e02.mkv", "two") + writeSrc(t, l, SectionTVSeries, "Bluey/poster.jpg", "art") + + res, err := l.Mark(SectionTVSeries, "Bluey") + if err != nil { + t.Fatal(err) + } + if res.Linked != 3 { + t.Fatalf("Linked = %d, want 3", res.Linked) + } + + // Every kids file must share an inode with its source. + srcFile := filepath.Join(l.sourceRoot(SectionTVSeries), "Bluey/Season 01/e01.mkv") + kidsFile := filepath.Join(l.kidsRoot(SectionTVSeries), "Bluey/Season 01/e01.mkv") + if !sameFile(srcFile, kidsFile) { + t.Fatal("kids file is not a hardlink of the source") + } + + // Re-running links nothing new. + res2, err := l.Mark(SectionTVSeries, "Bluey") + if err != nil { + t.Fatal(err) + } + if res2.Linked != 0 || res2.Unchanged != 3 { + t.Fatalf("second Mark = %+v, want 0 linked / 3 unchanged", res2) + } +} + +func TestMarkSyncsNewFilesAndReplacesStaleLinks(t *testing.T) { + l := newLib(t) + writeSrc(t, l, SectionTVSeries, "Bluey/e01.mkv", "one") + if _, err := l.Mark(SectionTVSeries, "Bluey"); err != nil { + t.Fatal(err) + } + + // A new episode arrives and an existing file is replaced upstream (a new + // inode with the same name, as an *arr upgrade does). + writeSrc(t, l, SectionTVSeries, "Bluey/e02.mkv", "two") + if err := os.Remove(filepath.Join(l.sourceRoot(SectionTVSeries), "Bluey/e01.mkv")); err != nil { + t.Fatal(err) + } + writeSrc(t, l, SectionTVSeries, "Bluey/e01.mkv", "one-upgraded") + + tit, err := l.Stat(SectionTVSeries, "Bluey") + if err != nil { + t.Fatal(err) + } + if !tit.NeedsSync || tit.UnlinkedFiles != 2 { + t.Fatalf("drift = %+v, want NeedsSync with 2 unlinked", tit) + } + + res, err := l.Mark(SectionTVSeries, "Bluey") + if err != nil { + t.Fatal(err) + } + if res.Linked != 1 || res.Replaced != 1 { + t.Fatalf("sync = %+v, want 1 linked / 1 replaced", res) + } + tit, err = l.Stat(SectionTVSeries, "Bluey") + if err != nil { + t.Fatal(err) + } + if tit.NeedsSync || tit.UnlinkedFiles != 0 { + t.Fatalf("post-sync drift = %+v, want none", tit) + } + b, err := os.ReadFile(filepath.Join(l.kidsRoot(SectionTVSeries), "Bluey/e01.mkv")) + if err != nil || string(b) != "one-upgraded" { + t.Fatalf("stale link not replaced: %q %v", b, err) + } +} + +func TestMarkSkipsSymlinks(t *testing.T) { + l := newLib(t) + writeSrc(t, l, SectionMovies, "Nemo/movie.mkv", "film") + link := filepath.Join(l.sourceRoot(SectionMovies), "Nemo", "elsewhere.mkv") + if err := os.Symlink("/dev/null", link); err != nil { + t.Skipf("symlinks unsupported: %v", err) + } + res, err := l.Mark(SectionMovies, "Nemo") + if err != nil { + t.Fatal(err) + } + if res.Linked != 1 || res.Skipped != 1 { + t.Fatalf("res = %+v, want 1 linked / 1 skipped", res) + } + if _, err := os.Lstat(filepath.Join(l.kidsRoot(SectionMovies), "Nemo", "elsewhere.mkv")); !os.IsNotExist(err) { + t.Fatal("symlink was copied into the kids tree") + } +} + +func TestUnmarkLeavesSourceIntact(t *testing.T) { + l := newLib(t) + writeSrc(t, l, SectionMovies, "Nemo/movie.mkv", "film") + if _, err := l.Mark(SectionMovies, "Nemo"); err != nil { + t.Fatal(err) + } + if err := l.Unmark(SectionMovies, "Nemo"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(l.kidsRoot(SectionMovies), "Nemo")); !os.IsNotExist(err) { + t.Fatal("kids directory survived unmark") + } + b, err := os.ReadFile(filepath.Join(l.sourceRoot(SectionMovies), "Nemo/movie.mkv")) + if err != nil || string(b) != "film" { + t.Fatalf("source damaged by unmark: %q %v", b, err) + } + // Unmarking again is a no-op, not an error. + if err := l.Unmark(SectionMovies, "Nemo"); err != nil { + t.Fatalf("second Unmark = %v, want nil", err) + } +} + +func TestStatAndListReportSizeCountAndMarks(t *testing.T) { + l := newLib(t) + writeSrc(t, l, SectionMovies, "Zootopia/movie.mkv", "0123456789") + writeSrc(t, l, SectionMovies, "Zootopia/extra.nfo", "abc") + writeSrc(t, l, SectionMovies, "aladdin/movie.mkv", "xy") + if _, err := l.Mark(SectionMovies, "Zootopia"); err != nil { + t.Fatal(err) + } + + got, err := l.Stat(SectionMovies, "Zootopia") + if err != nil { + t.Fatal(err) + } + if got.FileCount != 2 || got.SizeBytes != 13 || !got.Marked || got.NeedsSync { + t.Fatalf("Stat = %+v, want 2 files / 13 bytes / marked / synced", got) + } + + list, err := l.List(SectionMovies) + if err != nil { + t.Fatal(err) + } + if len(list) != 2 { + t.Fatalf("List returned %d titles, want 2", len(list)) + } + // Case-insensitive ordering: "aladdin" before "Zootopia". + if list[0].Name != "aladdin" || list[1].Name != "Zootopia" { + t.Fatalf("List order = %q, %q", list[0].Name, list[1].Name) + } + if list[0].Marked || !list[1].Marked { + t.Fatalf("marked flags wrong: %+v", list) + } +} + +func TestListMissingSectionIsEmptyNotError(t *testing.T) { + l := New(t.TempDir()) + got, err := l.List(SectionMovies) + if err != nil { + t.Fatalf("List = %v, want nil error", err) + } + if len(got) != 0 { + t.Fatalf("List = %v, want empty", got) + } +} + +func TestStatMissingTitleIsNotFound(t *testing.T) { + l := newLib(t) + if _, err := l.Stat(SectionMovies, "Ghost"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Stat = %v, want ErrNotFound", err) + } + if _, err := l.Mark(SectionMovies, "Ghost"); !errors.Is(err, ErrNotFound) { + t.Fatalf("Mark = %v, want ErrNotFound", err) + } +} + +func TestParseSection(t *testing.T) { + for _, ok := range []string{"movies", "tvseries"} { + if _, err := ParseSection(ok); err != nil { + t.Errorf("ParseSection(%q) = %v", ok, err) + } + } + for _, bad := range []string{"", "Movies", "music", "../movies", "tvseries/x"} { + if _, err := ParseSection(bad); !errors.Is(err, ErrInvalidSection) { + t.Errorf("ParseSection(%q) = %v, want ErrInvalidSection", bad, err) + } + } +} + +func TestReady(t *testing.T) { + l := newLib(t) + if err := l.Ready(); err != nil { + t.Fatalf("Ready = %v", err) + } + if err := New(filepath.Join(t.TempDir(), "absent")).Ready(); err == nil { + t.Fatal("Ready on a missing root = nil, want error") + } + f := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(f, nil, 0o644); err != nil { + t.Fatal(err) + } + if err := New(f).Ready(); err == nil { + t.Fatal("Ready on a file root = nil, want error") + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..57d656d --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,261 @@ +// Package server wires the mediamark HTTP surface: the library JSON API, the +// key-brokered poster proxy, health probes, and the embedded SPA. +package server + +import ( + "encoding/json" + "errors" + "io" + "io/fs" + "log/slog" + "net/http" + "strconv" + "strings" + + "git.unkin.net/unkin/mediamark/internal/arr" + "git.unkin.net/unkin/mediamark/internal/auth" + "git.unkin.net/unkin/mediamark/internal/config" + "git.unkin.net/unkin/mediamark/internal/library" +) + +// Server holds the resolved dependencies of the app. +type Server struct { + cfg *config.Config + lib *library.Library + arrs map[library.Section]*arr.Client + gate *auth.Middleware + assets fs.FS + log *slog.Logger +} + +// New constructs a Server. arrs maps a section to the *arr instance that owns +// it; a missing entry simply means titles in that section carry no metadata. +func New(cfg *config.Config, lib *library.Library, arrs map[library.Section]*arr.Client, assets fs.FS, log *slog.Logger) *Server { + if log == nil { + log = slog.Default() + } + return &Server{ + cfg: cfg, + lib: lib, + arrs: arrs, + gate: auth.New(cfg.GroupsHeader, cfg.AllowedGroups), + assets: assets, + log: log, + } +} + +// Handler returns the root handler. Health probes are ungated (kubelet sends no +// identity header); everything else — API and UI alike — is behind the group +// gate, so an unauthorized user cannot even load the page shell. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /livez", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("GET /readyz", s.readyz) + + gated := http.NewServeMux() + gated.HandleFunc("GET /api/library/{section}", s.handleList) + gated.HandleFunc("POST /api/library/{section}/{name}/mark", s.handleMark) + gated.HandleFunc("POST /api/library/{section}/{name}/unmark", s.handleUnmark) + gated.HandleFunc("GET /api/art/{section}/{id}", s.handleArt) + gated.HandleFunc("/", s.handleUI) + + mux.Handle("/", s.gate.Wrap(gated)) + return mux +} + +func (s *Server) readyz(w http.ResponseWriter, _ *http.Request) { + if err := s.lib.Ready(); err != nil { + s.log.Warn("readyz: media root unavailable", "err", err) + http.Error(w, "media root unavailable", http.StatusServiceUnavailable) + return + } + _, _ = w.Write([]byte("ok")) +} + +// arrMeta is the *arr metadata attached to a library title, or null. +type arrMeta struct { + ID int `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + Overview string `json:"overview"` + Status string `json:"status"` +} + +// titleView is one row of the library list API. +type titleView struct { + library.Title + Arr *arrMeta `json:"arr"` + ArtURL string `json:"artUrl,omitempty"` +} + +func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { + section, ok := s.section(w, r) + if !ok { + return + } + titles, err := s.lib.List(section) + if err != nil { + s.log.Error("list library", "section", section, "err", err) + writeErr(w, http.StatusInternalServerError, "cannot read library") + return + } + + // *arr metadata is a nicety: a down sonarr must not blank the list, so a + // lookup failure degrades to art-less rows. + byDir := map[string]arr.Entry{} + if c := s.arrs[section]; c != nil { + entries, err := c.List(r.Context()) + if err != nil { + s.log.Warn("arr list failed; serving library without metadata", "section", section, "err", err) + } + for _, e := range entries { + if d := e.Dir(); d != "" { + byDir[d] = e + } + } + } + + out := make([]titleView, 0, len(titles)) + for _, t := range titles { + v := titleView{Title: t} + if e, ok := byDir[t.Name]; ok { + v.Arr = &arrMeta{ID: e.ID, Title: e.Title, Year: e.Year, Overview: e.Overview, Status: e.Status} + v.ArtURL = "/api/art/" + string(section) + "/" + strconv.Itoa(e.ID) + } + out = append(out, v) + } + writeJSON(w, http.StatusOK, map[string]any{"section": string(section), "titles": out}) +} + +func (s *Server) handleMark(w http.ResponseWriter, r *http.Request) { + section, ok := s.section(w, r) + if !ok { + return + } + name := r.PathValue("name") + res, err := s.lib.Mark(section, name) + if err != nil { + s.writeLibErr(w, "mark", section, name, err) + return + } + s.log.Info("marked title", "section", section, "name", name, "linked", res.Linked, "replaced", res.Replaced) + s.writeState(w, section, name, res) +} + +func (s *Server) handleUnmark(w http.ResponseWriter, r *http.Request) { + section, ok := s.section(w, r) + if !ok { + return + } + name := r.PathValue("name") + if err := s.lib.Unmark(section, name); err != nil { + s.writeLibErr(w, "unmark", section, name, err) + return + } + s.log.Info("unmarked title", "section", section, "name", name) + s.writeState(w, section, name, library.SyncResult{}) +} + +// writeState answers a mutation with the title's fresh on-disk state so the UI +// never has to guess what the sync did. +func (s *Server) writeState(w http.ResponseWriter, section library.Section, name string, res library.SyncResult) { + t, err := s.lib.Stat(section, name) + if err != nil && !errors.Is(err, library.ErrNotFound) { + s.writeLibErr(w, "stat", section, name, err) + return + } + if errors.Is(err, library.ErrNotFound) { + // Unmarking a title whose source has since vanished still succeeded. + t = library.Title{Name: name} + } + writeJSON(w, http.StatusOK, map[string]any{"title": t, "sync": res}) +} + +func (s *Server) handleArt(w http.ResponseWriter, r *http.Request) { + section, ok := s.section(w, r) + if !ok { + return + } + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil || id <= 0 { + writeErr(w, http.StatusBadRequest, "invalid id") + return + } + c := s.arrs[section] + if c == nil { + writeErr(w, http.StatusNotFound, "no metadata source for section") + return + } + art, err := c.Poster(r.Context(), id) + if err != nil { + // The upstream error may name the app but never the key; still, answer + // with a fixed body so nothing upstream-shaped reaches the browser. + s.log.Warn("poster proxy failed", "section", section, "id", id, "err", err) + writeErr(w, http.StatusBadGateway, "poster unavailable") + return + } + defer func() { _ = art.Body.Close() }() + w.Header().Set("Content-Type", art.ContentType) + w.Header().Set("Cache-Control", "public, max-age=86400") + if _, err := io.Copy(w, art.Body); err != nil { + s.log.Warn("poster copy failed", "id", id, "err", err) + } +} + +// handleUI serves the embedded assets, falling back to index.html so client +// routes like /movies deep-link correctly. +func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + if strings.HasPrefix(p, "api/") { + writeErr(w, http.StatusNotFound, "not found") + return + } + if p != "" { + if st, err := fs.Stat(s.assets, p); err == nil && !st.IsDir() { + http.FileServerFS(s.assets).ServeHTTP(w, r) + return + } + } + b, err := fs.ReadFile(s.assets, "index.html") + if err != nil { + http.Error(w, "index missing", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(b) +} + +// section resolves and validates the {section} path value. +func (s *Server) section(w http.ResponseWriter, r *http.Request) (library.Section, bool) { + sec, err := library.ParseSection(r.PathValue("section")) + if err != nil { + writeErr(w, http.StatusNotFound, "unknown section") + return "", false + } + return sec, true +} + +// writeLibErr maps a library error onto a status without leaking paths. +func (s *Server) writeLibErr(w http.ResponseWriter, op string, section library.Section, name string, err error) { + switch { + case errors.Is(err, library.ErrInvalidName), errors.Is(err, library.ErrInvalidSection): + writeErr(w, http.StatusBadRequest, "invalid title") + case errors.Is(err, library.ErrNotFound): + writeErr(w, http.StatusNotFound, "title not found") + default: + s.log.Error("library operation failed", "op", op, "section", section, "name", name, "err", err) + writeErr(w, http.StatusInternalServerError, "library operation failed") + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..0e4ed29 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,364 @@ +package server + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "git.unkin.net/unkin/mediamark/internal/arr" + "git.unkin.net/unkin/mediamark/internal/config" + "git.unkin.net/unkin/mediamark/internal/library" +) + +const upstreamKey = "upstream-secret-key" + +// testEnv is a fully wired server over a temp media root and a fake *arr. +type testEnv struct { + h http.Handler + root string + lib *library.Library +} + +func newEnv(t *testing.T, arrHandler http.HandlerFunc) *testEnv { + t.Helper() + root := t.TempDir() + for _, tree := range []string{library.SourceTree, library.KidsTree} { + for _, s := range library.Sections { + if err := os.MkdirAll(filepath.Join(root, tree, string(s)), 0o755); err != nil { + t.Fatal(err) + } + } + } + + arrs := map[library.Section]*arr.Client{} + if arrHandler != nil { + srv := httptest.NewServer(arrHandler) + t.Cleanup(srv.Close) + key := func(string) (string, bool) { return upstreamKey, true } + sonarr := arr.New(arr.KindSonarr, srv.URL, key) + radarr := arr.New(arr.KindRadarr, srv.URL, key) + sonarr.SetCacheTTL(0) + radarr.SetCacheTTL(0) + arrs[library.SectionTVSeries] = sonarr + arrs[library.SectionMovies] = radarr + } + + cfg := &config.Config{ + MediaRoot: root, + GroupsHeader: "X-Forwarded-Groups", + AllowedGroups: []string{"akP-mediamark-user"}, + } + lib := library.New(root) + assets := fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("<html>mediamark</html>")}, + "app.js": &fstest.MapFile{Data: []byte("// js")}, + } + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + return &testEnv{h: New(cfg, lib, arrs, assets, log).Handler(), root: root, lib: lib} +} + +// do issues an authorized request. +func (e *testEnv) do(t *testing.T, method, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, nil) + req.Header.Set("X-Forwarded-Groups", "akP-other,akP-mediamark-user") + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + return rec +} + +func (e *testEnv) writeTitle(t *testing.T, section library.Section, name, rel, content string) { + t.Helper() + p := filepath.Join(e.root, library.SourceTree, string(section), name, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestHealthProbesAreUngated(t *testing.T) { + e := newEnv(t, nil) + for _, p := range []string{"/livez", "/readyz"} { + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("%s = %d, want 200", p, rec.Code) + } + } +} + +func TestReadyzFailsWhenMediaRootMissing(t *testing.T) { + cfg := &config.Config{MediaRoot: "/definitely/not/here", GroupsHeader: "X-G", AllowedGroups: []string{"g"}} + h := New(cfg, library.New(cfg.MediaRoot), nil, fstest.MapFS{}, slog.New(slog.NewTextHandler(io.Discard, nil))).Handler() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("readyz = %d, want 503", rec.Code) + } +} + +// Every gated surface — API, mutations, art, and the page shell — must 403 for +// a caller outside the allowed group. +func TestUnauthorizedGroupIsRejectedEverywhere(t *testing.T) { + e := newEnv(t, nil) + paths := []struct{ method, path string }{ + {http.MethodGet, "/"}, + {http.MethodGet, "/movies"}, + {http.MethodGet, "/api/library/movies"}, + {http.MethodPost, "/api/library/movies/Nemo/mark"}, + {http.MethodPost, "/api/library/movies/Nemo/unmark"}, + {http.MethodGet, "/api/art/movies/1"}, + } + for _, p := range paths { + for _, groups := range []string{"", "akP-somethingelse"} { + req := httptest.NewRequest(p.method, p.path, nil) + if groups != "" { + req.Header.Set("X-Forwarded-Groups", groups) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("%s %s (groups=%q) = %d, want 403", p.method, p.path, groups, rec.Code) + } + } + } +} + +func TestListJSONContract(t *testing.T) { + e := newEnv(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Api-Key") != upstreamKey { + t.Errorf("upstream request missing key") + } + _, _ = io.WriteString(w, `[ + {"id":9,"title":"Finding Nemo","year":2003,"overview":"fish","status":"released","path":"/media/fafflix/movies/Finding Nemo (2003)"} + ]`) + }) + e.writeTitle(t, library.SectionMovies, "Finding Nemo (2003)", "movie.mkv", "0123456789") + e.writeTitle(t, library.SectionMovies, "Unknown Film", "movie.mkv", "ab") + + rec := e.do(t, http.MethodGet, "/api/library/movies") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body %s", rec.Code, rec.Body) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q", ct) + } + var body struct { + Section string `json:"section"` + Titles []struct { + Name string `json:"name"` + Marked bool `json:"marked"` + NeedsSync bool `json:"needsSync"` + SizeBytes int64 `json:"sizeBytes"` + FileCount int `json:"fileCount"` + ArtURL string `json:"artUrl"` + Arr *struct { + ID int `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + } `json:"arr"` + } `json:"titles"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Section != "movies" || len(body.Titles) != 2 { + t.Fatalf("body = %+v", body) + } + // Sorted case-insensitively: "Finding Nemo (2003)" then "Unknown Film". + matched, unmatched := body.Titles[0], body.Titles[1] + if matched.Arr == nil || matched.Arr.ID != 9 || matched.Arr.Year != 2003 { + t.Fatalf("matched title missing arr metadata: %+v", matched) + } + if matched.ArtURL != "/api/art/movies/9" { + t.Errorf("artUrl = %q", matched.ArtURL) + } + if matched.SizeBytes != 10 || matched.FileCount != 1 || matched.Marked { + t.Errorf("matched stats = %+v", matched) + } + if unmatched.Arr != nil || unmatched.ArtURL != "" { + t.Errorf("unmatched title should carry no arr metadata: %+v", unmatched) + } + // The upstream key must never surface in the response. + if strings.Contains(rec.Body.String(), upstreamKey) { + t.Fatal("list response leaked the upstream api key") + } +} + +func TestListDegradesWhenArrIsDown(t *testing.T) { + e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "boom "+upstreamKey, http.StatusInternalServerError) + }) + e.writeTitle(t, library.SectionMovies, "Nemo", "movie.mkv", "x") + + rec := e.do(t, http.MethodGet, "/api/library/movies") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 with degraded metadata", rec.Code) + } + if !strings.Contains(rec.Body.String(), `"name":"Nemo"`) { + t.Fatalf("title missing from degraded list: %s", rec.Body) + } + if strings.Contains(rec.Body.String(), upstreamKey) { + t.Fatal("degraded list leaked the upstream api key") + } +} + +func TestMarkAndUnmarkRoundTrip(t *testing.T) { + e := newEnv(t, nil) + e.writeTitle(t, library.SectionTVSeries, "Bluey", "s01/e01.mkv", "one") + e.writeTitle(t, library.SectionTVSeries, "Bluey", "s01/e02.mkv", "two") + + rec := e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/mark") + if rec.Code != http.StatusOK { + t.Fatalf("mark = %d body %s", rec.Code, rec.Body) + } + var marked struct { + Title library.Title `json:"title"` + Sync library.SyncResult `json:"sync"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &marked); err != nil { + t.Fatal(err) + } + if !marked.Title.Marked || marked.Title.NeedsSync || marked.Sync.Linked != 2 { + t.Fatalf("mark result = %+v", marked) + } + if _, err := os.Stat(filepath.Join(e.root, library.KidsTree, "tvseries", "Bluey", "s01", "e01.mkv")); err != nil { + t.Fatalf("hardlink missing: %v", err) + } + + // Marking again is idempotent. + rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/mark") + if rec.Code != http.StatusOK { + t.Fatalf("second mark = %d", rec.Code) + } + + rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/unmark") + if rec.Code != http.StatusOK { + t.Fatalf("unmark = %d body %s", rec.Code, rec.Body) + } + var unmarked struct { + Title library.Title `json:"title"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &unmarked); err != nil { + t.Fatal(err) + } + if unmarked.Title.Marked { + t.Fatalf("still marked after unmark: %+v", unmarked) + } + if _, err := os.Stat(filepath.Join(e.root, library.KidsTree, "tvseries", "Bluey")); !os.IsNotExist(err) { + t.Fatal("kids directory survived unmark") + } + // Unmarking again is idempotent. + if rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/unmark"); rec.Code != http.StatusOK { + t.Fatalf("second unmark = %d", rec.Code) + } +} + +func TestMutationRejectsBadSectionAndName(t *testing.T) { + e := newEnv(t, nil) + cases := []struct { + path string + want int + }{ + {"/api/library/music/Nemo/mark", http.StatusNotFound}, + {"/api/library/movies/Ghost/mark", http.StatusNotFound}, + {"/api/library/movies/..%2f..%2fetc/mark", http.StatusBadRequest}, + {`/api/library/movies/..\..\etc/mark`, http.StatusBadRequest}, + // A literal ../ never reaches a handler: net/http's mux normalises the + // path and redirects, so no mutation runs. + {"/api/library/movies/../mark", http.StatusTemporaryRedirect}, + } + for _, c := range cases { + rec := e.do(t, http.MethodPost, c.path) + if rec.Code != c.want { + t.Errorf("POST %s = %d, want %d (%s)", c.path, rec.Code, c.want, rec.Body) + } + } +} + +func TestArtProxyStreamsAndHidesTheKey(t *testing.T) { + e := newEnv(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v3/mediacover/9/poster-250.jpg" { + t.Errorf("art path = %q", r.URL.Path) + } + if r.Header.Get("X-Api-Key") != upstreamKey { + t.Error("art request missing the api key") + } + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("JPEGDATA")) + }) + + rec := e.do(t, http.MethodGet, "/api/art/movies/9") + if rec.Code != http.StatusOK { + t.Fatalf("art = %d body %s", rec.Code, rec.Body) + } + if got := rec.Header().Get("Content-Type"); got != "image/jpeg" { + t.Errorf("Content-Type = %q", got) + } + if got := rec.Header().Get("Cache-Control"); got != "public, max-age=86400" { + t.Errorf("Cache-Control = %q", got) + } + if rec.Body.String() != "JPEGDATA" { + t.Errorf("body = %q", rec.Body) + } + for k, vs := range rec.Header() { + for _, v := range vs { + if strings.Contains(v, upstreamKey) { + t.Fatalf("header %s leaked the api key", k) + } + } + } +} + +func TestArtProxyErrorsAreOpaque(t *testing.T) { + e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "upstream said "+upstreamKey, http.StatusInternalServerError) + }) + rec := e.do(t, http.MethodGet, "/api/art/movies/9") + if rec.Code != http.StatusBadGateway { + t.Fatalf("art error status = %d, want 502", rec.Code) + } + if strings.Contains(rec.Body.String(), upstreamKey) { + t.Fatalf("art error leaked the api key: %s", rec.Body) + } +} + +func TestArtRejectsBadIDAndUnknownSection(t *testing.T) { + e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("x")) }) + for path, want := range map[string]int{ + "/api/art/movies/0": http.StatusBadRequest, + "/api/art/movies/abc": http.StatusBadRequest, + "/api/art/music/1": http.StatusNotFound, + } { + if rec := e.do(t, http.MethodGet, path); rec.Code != want { + t.Errorf("GET %s = %d, want %d", path, rec.Code, want) + } + } +} + +func TestSPAFallbackAndAssets(t *testing.T) { + e := newEnv(t, nil) + if rec := e.do(t, http.MethodGet, "/app.js"); rec.Code != http.StatusOK || rec.Body.String() != "// js" { + t.Fatalf("asset = %d %q", rec.Code, rec.Body) + } + for _, p := range []string{"/", "/movies", "/tvseries/Bluey"} { + rec := e.do(t, http.MethodGet, p) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "mediamark") { + t.Errorf("SPA fallback for %s = %d %q", p, rec.Code, rec.Body) + } + } + // Unknown API routes must 404 as JSON rather than falling back to the SPA. + rec := e.do(t, http.MethodGet, "/api/nope") + if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html>") { + t.Errorf("unknown api route = %d %q", rec.Code, rec.Body) + } +} diff --git a/ui/embed.go b/ui/embed.go new file mode 100644 index 0000000..afadac6 --- /dev/null +++ b/ui/embed.go @@ -0,0 +1,19 @@ +// Package ui embeds the static mediamark assets. +package ui + +import ( + "embed" + "io/fs" +) + +//go:embed static +var embedded embed.FS + +// Assets returns the static asset filesystem rooted at the asset directory. +func Assets() fs.FS { + sub, err := fs.Sub(embedded, "static") + if err != nil { + panic(err) + } + return sub +} diff --git a/ui/static/app.css b/ui/static/app.css new file mode 100644 index 0000000..bbf4dc9 --- /dev/null +++ b/ui/static/app.css @@ -0,0 +1,286 @@ +/* Arrstack UI — Bootstrap-3-style layout + material-elevation polish, dressed + in a pirates-in-space / high-seas theme. Fully self-contained (no CDNs). */ + +:root { + --sky-deep: #070d1f; + --sea-deep: #0f2733; + --sea: #16394a; + --sea-mid: #1d4a5e; + --parchment: #f2e4c9; + --parchment-dk: #e4d0a7; + --rope: #b8894b; + --rope-dk: #8a5f2c; + --brass: #c9a25a; + --ink: #2b2015; + --danger: #a5382e; + --danger-dk: #7e281f; + --ok: #3c6e4f; + --shadow: 0 2px 6px rgba(0,0,0,.25), 0 8px 24px rgba(0,0,0,.18); + --radius: 6px; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + color: var(--ink); + background-color: var(--sky-deep); + background-image: url("bg-space-pirate.svg"); + background-size: cover; + background-position: center top; + background-attachment: fixed; + background-repeat: no-repeat; + min-height: 100vh; +} + +.container { max-width: 960px; margin: 0 auto; padding: 0 16px; } + +/* ---- navbar (rope-trimmed) ---- */ +.navbar { + background: linear-gradient(180deg, var(--sea) 0%, var(--sea-deep) 100%); + border-bottom: 4px solid var(--rope); + box-shadow: var(--shadow); +} +.navbar-inner { display: flex; align-items: baseline; gap: 12px; padding: 14px 16px; flex-wrap: wrap; } +.brand { color: var(--parchment); font-size: 1.5rem; font-weight: 700; letter-spacing: .5px; } +.brand-mark { color: var(--brass); } +.brand-tag { color: var(--parchment-dk); font-style: italic; opacity: .85; } +.whoami { margin-left: auto; color: var(--parchment-dk); font-size: .9rem; } + +/* ---- hero ---- */ +.hero { padding: 32px 0 8px; } +/* Hero copy sits directly on the dark starfield, so it uses light pirate-gold / + parchment tones with a dark scrim shadow for readability over stars. */ +.hero h1 { margin: 0 0 6px; font-size: 2rem; color: var(--brass); text-shadow: 0 1px 4px rgba(0,0,0,.85); } +.lead { font-size: 1.1rem; color: var(--parchment); margin: 0; text-shadow: 0 1px 3px rgba(0,0,0,.85); } + +/* ---- app icon grid (square tiles) ---- */ +.app-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 16px; + margin: 24px 0; +} +.app-tile { + aspect-ratio: 1 / 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + background: #fff8ea; + border: 1px solid var(--parchment-dk); + border-top: 4px solid var(--sea-mid); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 16px; + text-decoration: none; + color: var(--ink); + transition: transform .12s ease, box-shadow .12s ease; +} +a.app-tile:hover { transform: translateY(-3px); box-shadow: 0 6px 14px rgba(0,0,0,.28); } +.app-tile-icon { width: 55%; max-width: 96px; height: auto; } +.app-tile-name { font-weight: 700; font-size: 1.05rem; text-transform: capitalize; } +.app-tile.locked { opacity: .55; filter: grayscale(.4); cursor: not-allowed; } +.badge-locked { font-size: .7rem; background: var(--danger); color: #fff; padding: 2px 6px; border-radius: 3px; } + +/* ---- panels ---- */ +.panel { + background: #fff8ea; + border: 1px solid var(--parchment-dk); + border-radius: var(--radius); + box-shadow: var(--shadow); + margin: 24px 0 48px; +} +.panel-heading { + display: flex; align-items: center; gap: 10px; + background: linear-gradient(180deg, var(--sea-mid), var(--sea)); + color: var(--parchment); + padding: 14px 20px; + border-radius: var(--radius) var(--radius) 0 0; + border-bottom: 3px solid var(--rope); +} +.panel-heading h2 { margin: 0; font-size: 1.25rem; } +.panel-icon { font-size: 1.4rem; } +.panel-body { padding: 20px; } + +/* ---- forms ---- */ +.form-group { margin-bottom: 16px; } +label { display: block; font-weight: 600; margin-bottom: 6px; color: var(--sea-deep); } +.form-control { + width: 100%; padding: 9px 12px; + border: 1px solid var(--parchment-dk); border-radius: var(--radius); + background: #fffdf6; font-size: 1rem; + transition: border-color .12s ease, box-shadow .12s ease; +} +.form-control:focus { outline: none; border-color: var(--brass); box-shadow: 0 0 0 3px rgba(201,162,90,.3); } +.app-checks { display: flex; flex-wrap: wrap; gap: 14px; } +.app-checks label { font-weight: 400; display: inline-flex; align-items: center; gap: 6px; margin: 0; } + +/* ---- buttons (material elevation + ripple-ish) ---- */ +.btn { + display: inline-block; border: none; border-radius: var(--radius); + padding: 10px 18px; font-size: 1rem; font-weight: 600; cursor: pointer; + box-shadow: 0 2px 4px rgba(0,0,0,.2); transition: transform .08s ease, box-shadow .12s ease, background .12s ease; + text-decoration: none; text-align: center; +} +.btn:active { transform: translateY(1px); box-shadow: 0 1px 2px rgba(0,0,0,.2); } +.btn-primary { background: var(--rope); color: #2b1c08; } +.btn-primary:hover { background: var(--rope-dk); color: #fff; } +.btn-copy { background: var(--brass); color: var(--ink); padding: 6px 12px; } +.btn-danger { background: var(--danger); color: #fff; padding: 5px 12px; font-size: .85rem; } +.btn-danger:hover { background: var(--danger-dk); } +.btn:disabled { opacity: .55; cursor: not-allowed; } + +/* ---- token reveal ---- */ +.mint-result { margin-top: 18px; padding: 16px; background: #2b2015; border-radius: var(--radius); } +.mint-result .muted { color: var(--parchment-dk); } +.token-reveal { display: flex; gap: 10px; align-items: center; } +.token-reveal code { + flex: 1; background: #12100b; color: #7ee0a1; padding: 10px 12px; + border-radius: var(--radius); word-break: break-all; font-family: "SF Mono", Menlo, Consolas, monospace; +} + +/* ---- keys list ---- */ +.keys-title { margin-top: 28px; border-top: 2px dashed var(--parchment-dk); padding-top: 18px; color: var(--rope-dk); } +.keys-list { display: flex; flex-direction: column; gap: 10px; } +.key-row { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + background: #fffdf6; border: 1px solid var(--parchment-dk); border-left: 4px solid var(--sea-mid); + border-radius: var(--radius); padding: 12px 14px; +} +.key-row.disabled { border-left-color: var(--danger); opacity: .6; } +.key-label { font-weight: 700; } +.key-meta { color: #7a6b52; font-size: .85rem; } +.key-apps { display: flex; gap: 6px; } +.chip { background: var(--sea-mid); color: var(--parchment); font-size: .72rem; padding: 2px 8px; border-radius: 10px; text-transform: capitalize; } +.key-row .spacer { margin-left: auto; } + +/* ---- utility ---- */ +.muted { color: #7a6b52; } +.hidden { display: none !important; } +code { font-family: "SF Mono", Menlo, Consolas, monospace; } + +/* ---- toast ---- */ +.toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + background: var(--sea-deep); color: var(--parchment); + padding: 12px 20px; border-radius: var(--radius); box-shadow: var(--shadow); + border-left: 4px solid var(--brass); z-index: 50; +} +.toast.err { border-left-color: var(--danger); } + +/* ========================================================================== + Mediamark additions — landing tiles, sticky search, title list, detail. + Same palette and elevation language as the arrstack skin above. + ========================================================================== */ + +a.brand { text-decoration: none; } +.view.hidden { display: none; } +kbd { + font-family: "SF Mono", Menlo, Consolas, monospace; font-size: .72rem; + background: var(--ink); color: var(--parchment); padding: 1px 6px; + border-radius: 3px; border-bottom: 2px solid #000; +} + +/* ---- landing: exactly two big square tiles ---- */ +.app-grid-2 { grid-template-columns: repeat(2, 1fr); max-width: 620px; gap: 24px; } +.app-tile-icon { font-size: 3.4rem; line-height: 1; width: auto; } +.app-tile-key { font-size: .78rem; color: #7a6b52; } +.app-tile.selected { + transform: translateY(-3px); + border-top-color: var(--brass); + box-shadow: 0 0 0 3px rgba(201,162,90,.55), 0 6px 14px rgba(0,0,0,.28); +} + +/* ---- sticky search bar ---- */ +.searchbar { + position: sticky; top: 0; z-index: 20; + background: linear-gradient(180deg, var(--sea) 0%, var(--sea-deep) 100%); + border-bottom: 4px solid var(--rope); + box-shadow: var(--shadow); +} +.searchbar-inner { display: flex; align-items: center; gap: 10px; padding: 12px 16px; } +.searchbar .form-control { flex: 1; font-size: 1.05rem; } +.search-count { color: var(--parchment-dk); font-size: .85rem; white-space: nowrap; } +.btn-back { + background: var(--sea-mid); color: var(--parchment); + padding: 8px 14px; font-size: 1.1rem; line-height: 1; +} +.btn-back:hover { background: var(--rope); color: var(--ink); } +.btn-back-block { display: inline-block; margin: 20px 0 0; } +.section-title { + color: var(--brass); text-shadow: 0 1px 4px rgba(0,0,0,.85); + margin: 20px 0 12px; font-size: 1.6rem; +} + +/* ---- title list ---- */ +.title-list { display: flex; flex-direction: column; gap: 8px; padding-bottom: 24px; outline: none; } +.title-row { + display: flex; align-items: center; gap: 14px; + background: #fff8ea; border: 1px solid var(--parchment-dk); + border-left: 4px solid var(--sea-mid); border-radius: var(--radius); + box-shadow: var(--shadow); padding: 10px 14px; + text-decoration: none; color: var(--ink); + transition: transform .1s ease, box-shadow .1s ease; +} +.title-row:hover { transform: translateX(2px); } +.title-row.marked { border-left-color: var(--brass); } +.title-row.selected { + border-color: var(--brass); + box-shadow: 0 0 0 3px rgba(201,162,90,.55), var(--shadow); +} +.title-art { + width: 46px; height: 68px; flex: 0 0 46px; + border-radius: 3px; overflow: hidden; background: var(--sea-deep); + display: flex; align-items: center; justify-content: center; +} +.poster { width: 100%; height: 100%; object-fit: cover; display: block; } +.poster-placeholder { color: var(--parchment-dk); font-size: 1.4rem; font-weight: 700; } +.title-main { flex: 1; min-width: 0; } +.title-name { font-weight: 700; font-size: 1.02rem; overflow-wrap: anywhere; } +.title-name mark { background: rgba(201,162,90,.45); color: inherit; border-radius: 2px; padding: 0 1px; } +.title-sub { font-size: .82rem; } +.title-badges { display: flex; flex-direction: column; gap: 4px; align-items: flex-end; } +.badge { font-size: .68rem; padding: 2px 8px; border-radius: 10px; white-space: nowrap; font-weight: 700; } +.badge-marked { background: var(--brass); color: var(--ink); } +.badge-sync { background: var(--danger); color: #fff; } +.empty { padding: 20px 0 40px; } + +/* ---- detail ---- */ +.detail-body { display: flex; gap: 24px; flex-wrap: wrap; } +.detail-art { flex: 0 0 200px; } +.poster-lg { + width: 200px; height: 296px; border-radius: var(--radius); + box-shadow: var(--shadow); object-fit: cover; background: var(--sea-deep); +} +.poster-lg.poster-placeholder { display: flex; align-items: center; justify-content: center; font-size: 3rem; } +.detail-meta { flex: 1; min-width: 260px; } +.detail-facts { margin: 0 0 10px; color: var(--rope-dk); font-weight: 700; } +.detail-overview { margin: 0 0 18px; line-height: 1.5; } +.detail-stats { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; margin: 0 0 20px; } +.detail-stats dt { font-weight: 700; color: var(--sea-deep); } +.detail-stats dd { margin: 0; } +.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; } +.btn-lg { padding: 14px 24px; font-size: 1.05rem; } + +/* ---- footer key hints ---- */ +.keyhint { + padding: 14px 16px 28px; color: var(--parchment-dk); + font-size: .85rem; text-shadow: 0 1px 3px rgba(0,0,0,.85); +} + +/* ---- mobile ---- */ +@media (max-width: 600px) { + .container { padding: 0 10px; } + .app-grid-2 { gap: 12px; } + .app-tile-icon { font-size: 2.6rem; } + .title-row { padding: 8px 10px; gap: 10px; border-radius: 4px; } + .title-art { width: 38px; height: 56px; flex-basis: 38px; } + .title-badges { flex-direction: row; align-items: center; } + .detail-art { flex: 0 0 100%; } + .poster-lg { width: 100%; height: auto; aspect-ratio: 2 / 3; } + .detail-actions .btn { flex: 1; } + .hero h1 { font-size: 1.5rem; } +} diff --git a/ui/static/app.js b/ui/static/app.js new file mode 100644 index 0000000..854d5b2 --- /dev/null +++ b/ui/static/app.js @@ -0,0 +1,422 @@ +/* Mediamark SPA — keyboard-first library marking. No build step, no CDNs. */ +(function () { + "use strict"; + + var SECTIONS = { + movies: { label: "Movies", key: "m" }, + tvseries: { label: "TV Series", key: "t" } + }; + + var state = { + view: "home", + section: null, + titles: [], + filtered: [], + selected: 0, + detail: null, + homeTile: 0 + }; + + var el = {}; + ["view-home", "view-section", "view-detail", "search", "search-count", "section-title", + "title-list", "list-empty", "detail-title", "detail-poster", "detail-noposter", + "detail-facts", "detail-overview", "detail-size", "detail-files", "detail-marked", + "detail-toggle", "detail-sync", "detail-back", "toast", "keyhint-text", "home-tiles" + ].forEach(function (id) { el[id] = document.getElementById(id); }); + + /* ---------- fzf-ish subsequence matcher ---------- + Scores a subsequence match: contiguous runs and word-boundary hits score + higher, and an earlier first match wins ties. Returns null for no match. */ + function fuzzy(needle, haystack) { + if (!needle) return { score: 0, positions: [] }; + var n = needle.toLowerCase(), h = haystack.toLowerCase(); + var positions = [], score = 0, run = 0, hi = 0; + for (var ni = 0; ni < n.length; ni++) { + var ch = n.charAt(ni); + if (ch === " ") { run = 0; continue; } + var found = -1; + for (var j = hi; j < h.length; j++) { + if (h.charAt(j) === ch) { found = j; break; } + } + if (found < 0) return null; + positions.push(found); + score += 1; + if (found === hi && ni > 0) { run++; score += 4 + run; } else { run = 0; } + var prev = found > 0 ? h.charAt(found - 1) : ""; + if (found === 0) score += 8; + else if (prev === " " || prev === "-" || prev === "." || prev === "(" || prev === ":") score += 6; + hi = found + 1; + } + score -= positions[0] * 0.1; + score -= (h.length - n.length) * 0.01; + return { score: score, positions: positions }; + } + + function highlight(text, positions) { + if (!positions || !positions.length) return document.createTextNode(text); + var frag = document.createDocumentFragment(), at = 0; + for (var i = 0; i < positions.length; i++) { + var p = positions[i]; + if (p < at) continue; + if (p > at) frag.appendChild(document.createTextNode(text.slice(at, p))); + var m = document.createElement("mark"); + m.textContent = text.charAt(p); + frag.appendChild(m); + at = p + 1; + } + if (at < text.length) frag.appendChild(document.createTextNode(text.slice(at))); + return frag; + } + + /* ---------- helpers ---------- */ + function humanSize(bytes) { + if (!bytes) return "0 B"; + var units = ["B", "KiB", "MiB", "GiB", "TiB"], i = 0, v = bytes; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return (i === 0 ? v : v.toFixed(v < 10 ? 2 : 1)) + " " + units[i]; + } + + var toastTimer = null; + function toast(msg, isErr) { + el.toast.textContent = msg; + el.toast.className = "toast" + (isErr ? " err" : ""); + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { el.toast.className = "toast hidden"; }, 3200); + } + + function api(method, path) { + return fetch(path, { method: method, headers: { Accept: "application/json" } }) + .then(function (res) { + return res.json().catch(function () { return {}; }).then(function (body) { + if (!res.ok) throw new Error(body.error || ("request failed (" + res.status + ")")); + return body; + }); + }); + } + + function displayName(t) { return (t.arr && t.arr.title) || t.name; } + + /* ---------- rendering ---------- */ + function show(view) { + state.view = view; + el["view-home"].classList.toggle("hidden", view !== "home"); + el["view-section"].classList.toggle("hidden", view !== "section"); + el["view-detail"].classList.toggle("hidden", view !== "detail"); + el["keyhint-text"].textContent = + view === "home" ? "m movies · t tv series · ←/→ select · enter open" + : view === "section" ? "type to filter · ↑/↓ select · enter details · m toggle cheeztv · esc back" + : "m toggle cheeztv · esc back"; + } + + function applyFilter() { + var q = el.search.value.trim(); + var scored = []; + for (var i = 0; i < state.titles.length; i++) { + var t = state.titles[i]; + var r = fuzzy(q, displayName(t)); + if (!r && q) r = fuzzy(q, t.name); + if (r) scored.push({ t: t, score: r.score, positions: q ? r.positions : [] }); + } + if (q) scored.sort(function (a, b) { return b.score - a.score; }); + state.filtered = scored; + if (state.selected >= scored.length) state.selected = Math.max(0, scored.length - 1); + renderList(); + } + + function renderList() { + el["title-list"].textContent = ""; + el["list-empty"].classList.toggle("hidden", state.filtered.length > 0); + el["search-count"].textContent = state.filtered.length + " / " + state.titles.length; + + state.filtered.forEach(function (row, idx) { + var t = row.t; + var a = document.createElement("a"); + a.className = "title-row" + (idx === state.selected ? " selected" : "") + (t.marked ? " marked" : ""); + a.href = "#/" + state.section + "/" + encodeURIComponent(t.name); + a.setAttribute("role", "option"); + a.dataset.index = String(idx); + + var art = document.createElement("div"); + art.className = "title-art"; + if (t.artUrl) { + var img = document.createElement("img"); + img.src = t.artUrl; + img.alt = ""; + img.loading = "lazy"; + img.className = "poster"; + art.appendChild(img); + } else { + art.appendChild(document.createTextNode("?")); + art.classList.add("poster-placeholder"); + } + a.appendChild(art); + + var main = document.createElement("div"); + main.className = "title-main"; + var name = document.createElement("div"); + name.className = "title-name"; + name.appendChild(highlight(displayName(t), row.positions)); + main.appendChild(name); + var sub = document.createElement("div"); + sub.className = "title-sub muted"; + var bits = []; + if (t.arr && t.arr.year) bits.push(t.arr.year); + if (t.arr && t.arr.status) bits.push(t.arr.status); + bits.push(humanSize(t.sizeBytes)); + sub.textContent = bits.join(" · "); + main.appendChild(sub); + a.appendChild(main); + + var badges = document.createElement("div"); + badges.className = "title-badges"; + if (t.marked) badges.appendChild(badge("cheeztv", "badge-marked")); + if (t.needsSync) badges.appendChild(badge("needs sync", "badge-sync")); + a.appendChild(badges); + + el["title-list"].appendChild(a); + }); + } + + function badge(text, cls) { + var s = document.createElement("span"); + s.className = "badge " + cls; + s.textContent = text; + return s; + } + + // Arrowing moves focus out of the search box and onto the list, so the + // single-letter shortcuts (m) are unambiguous; typing any other character + // hands focus straight back to the search box. + function moveSelection(delta) { + if (!state.filtered.length) return; + state.selected = Math.min(state.filtered.length - 1, Math.max(0, state.selected + delta)); + renderList(); + el["title-list"].focus({ preventScroll: true }); + var sel = el["title-list"].querySelector(".title-row.selected"); + if (sel && sel.scrollIntoView) sel.scrollIntoView({ block: "nearest" }); + } + + function currentTitle() { + if (state.view === "detail") return state.detail; + var row = state.filtered[state.selected]; + return row ? row.t : null; + } + + /* ---------- data ---------- */ + function loadSection(section) { + state.section = section; + el["section-title"].textContent = SECTIONS[section].label; + document.title = "Mediamark — " + SECTIONS[section].label; + el["title-list"].textContent = ""; + return api("GET", "/api/library/" + section).then(function (body) { + state.titles = body.titles || []; + state.selected = 0; + applyFilter(); + }).catch(function (e) { + toast(e.message, true); + state.titles = []; + applyFilter(); + }); + } + + function mergeTitle(updated) { + for (var i = 0; i < state.titles.length; i++) { + if (state.titles[i].name === updated.name) { + ["marked", "needsSync", "sizeBytes", "fileCount", "unlinkedFiles"].forEach(function (k) { + state.titles[i][k] = updated[k]; + }); + if (state.detail && state.detail.name === updated.name) state.detail = state.titles[i]; + return state.titles[i]; + } + } + return null; + } + + function toggleMark(t) { + if (!t) return; + var wantMark = !t.marked; + var prev = { marked: t.marked, needsSync: t.needsSync }; + t.marked = wantMark; // optimistic + t.needsSync = false; + renderList(); + if (state.view === "detail") renderDetail(); + + api("POST", "/api/library/" + state.section + "/" + encodeURIComponent(t.name) + "/" + (wantMark ? "mark" : "unmark")) + .then(function (body) { + mergeTitle(body.title); + var s = body.sync || {}; + toast(wantMark + ? "Marked " + displayName(t) + " — " + (s.linked || 0) + " linked, " + (s.unchanged || 0) + " already there" + : "Removed " + displayName(t) + " from cheeztv"); + renderList(); + if (state.view === "detail") renderDetail(); + }) + .catch(function (e) { + t.marked = prev.marked; + t.needsSync = prev.needsSync; + renderList(); + if (state.view === "detail") renderDetail(); + toast(e.message, true); + }); + } + + function syncTitle(t) { + api("POST", "/api/library/" + state.section + "/" + encodeURIComponent(t.name) + "/mark") + .then(function (body) { + mergeTitle(body.title); + var s = body.sync || {}; + toast("Synced " + displayName(t) + " — " + (s.linked || 0) + " new, " + (s.replaced || 0) + " replaced"); + renderList(); + renderDetail(); + }) + .catch(function (e) { toast(e.message, true); }); + } + + /* ---------- detail ---------- */ + function renderDetail() { + var t = state.detail; + if (!t) return; + el["detail-title"].textContent = displayName(t); + el["detail-back"].href = "#/" + state.section; + + var facts = []; + if (t.arr && t.arr.year) facts.push(t.arr.year); + if (t.arr && t.arr.status) facts.push(t.arr.status); + facts.push(SECTIONS[state.section].label); + el["detail-facts"].textContent = facts.join(" · "); + el["detail-overview"].textContent = (t.arr && t.arr.overview) || "No overview available for this title."; + el["detail-size"].textContent = humanSize(t.sizeBytes); + el["detail-files"].textContent = String(t.fileCount); + el["detail-marked"].textContent = t.marked + ? (t.needsSync ? "marked (" + t.unlinkedFiles + " files unsynced)" : "marked") + : "not marked"; + + if (t.artUrl) { + el["detail-poster"].src = t.artUrl; + el["detail-poster"].classList.remove("hidden"); + el["detail-noposter"].classList.add("hidden"); + } else { + el["detail-poster"].classList.add("hidden"); + el["detail-noposter"].classList.remove("hidden"); + } + + el["detail-toggle"].textContent = t.marked ? "Remove from cheeztv" : "Add to cheeztv"; + el["detail-toggle"].className = "btn " + (t.marked ? "btn-danger btn-lg" : "btn-primary btn-lg"); + el["detail-sync"].classList.toggle("hidden", !t.needsSync); + } + + function openDetail(section, name) { + var go = state.section === section && state.titles.length + ? Promise.resolve() + : loadSection(section); + return go.then(function () { + var found = null; + for (var i = 0; i < state.titles.length; i++) { + if (state.titles[i].name === name) { found = state.titles[i]; break; } + } + if (!found) { toast("Title not found", true); location.hash = "#/" + section; return; } + state.detail = found; + show("detail"); + renderDetail(); + }); + } + + /* ---------- routing ---------- */ + function route() { + var parts = (location.hash || "#/").replace(/^#\/?/, "").split("/"); + var section = parts[0]; + if (!SECTIONS[section]) { + show("home"); + document.title = "Mediamark"; + focusTile(); + return; + } + if (parts.length > 1 && parts[1]) { + openDetail(section, decodeURIComponent(parts[1])); + return; + } + show("section"); + var reload = state.section === section && state.titles.length + ? Promise.resolve(applyFilter()) + : loadSection(section); + reload.then(function () { el.search.focus(); el.search.select(); }); + } + + function tiles() { return Array.prototype.slice.call(el["home-tiles"].querySelectorAll(".app-tile")); } + + function focusTile() { + var ts = tiles(); + ts.forEach(function (t, i) { t.classList.toggle("selected", i === state.homeTile); }); + } + + /* ---------- keyboard ---------- */ + document.addEventListener("keydown", function (ev) { + if (ev.ctrlKey || ev.metaKey || ev.altKey) return; + var typingInSearch = ev.target === el.search; + + if (state.view === "home") { + var ts = tiles(); + if (ev.key === "m") { location.hash = "#/movies"; ev.preventDefault(); return; } + if (ev.key === "t") { location.hash = "#/tvseries"; ev.preventDefault(); return; } + if (ev.key === "ArrowRight" || ev.key === "ArrowDown") { + state.homeTile = Math.min(ts.length - 1, state.homeTile + 1); focusTile(); ev.preventDefault(); return; + } + if (ev.key === "ArrowLeft" || ev.key === "ArrowUp") { + state.homeTile = Math.max(0, state.homeTile - 1); focusTile(); ev.preventDefault(); return; + } + if (ev.key === "Enter") { ts[state.homeTile].click(); ev.preventDefault(); } + return; + } + + if (ev.key === "Escape") { + if (state.view === "detail") { location.hash = "#/" + state.section; } + else if (typingInSearch && el.search.value) { el.search.value = ""; applyFilter(); } + else { location.hash = "#/"; } + ev.preventDefault(); + return; + } + + if (state.view === "detail") { + if (ev.key === "m") { toggleMark(state.detail); ev.preventDefault(); } + return; + } + + // Section view. + if (ev.key === "ArrowDown") { moveSelection(1); ev.preventDefault(); return; } + if (ev.key === "ArrowUp") { moveSelection(-1); ev.preventDefault(); return; } + if (ev.key === "Enter") { + var t = currentTitle(); + if (t) location.hash = "#/" + state.section + "/" + encodeURIComponent(t.name); + ev.preventDefault(); + return; + } + if (ev.key === "m" && !typingInSearch) { toggleMark(currentTitle()); ev.preventDefault(); return; } + if (!typingInSearch && ev.key.length === 1) { + el.search.focus(); + el.search.value += ev.key; + state.selected = 0; + applyFilter(); + ev.preventDefault(); + } + }); + + el.search.addEventListener("input", function () { state.selected = 0; applyFilter(); }); + + el["title-list"].addEventListener("click", function (ev) { + var row = ev.target.closest ? ev.target.closest(".title-row") : null; + if (row) state.selected = Number(row.dataset.index); + }); + + el["home-tiles"].addEventListener("mouseover", function (ev) { + var tile = ev.target.closest ? ev.target.closest(".app-tile") : null; + if (!tile) return; + state.homeTile = tiles().indexOf(tile); + focusTile(); + }); + + el["detail-toggle"].addEventListener("click", function () { toggleMark(state.detail); }); + el["detail-sync"].addEventListener("click", function () { syncTitle(state.detail); }); + + window.addEventListener("hashchange", route); + route(); +})(); diff --git a/ui/static/bg-space-pirate.svg b/ui/static/bg-space-pirate.svg new file mode 100644 index 0000000..ea962f3 --- /dev/null +++ b/ui/static/bg-space-pirate.svg @@ -0,0 +1,223 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 960" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Cartoon pirate ship sailing a cosmic sea through outer space"> + <defs> + <radialGradient id="sky" cx="50%" cy="40%" r="80%"> + <stop offset="0%" stop-color="#12213f"/> + <stop offset="55%" stop-color="#0c1730"/> + <stop offset="100%" stop-color="#070d1f"/> + </radialGradient> + <linearGradient id="wave" x1="0" y1="0" x2="0" y2="1"> + <stop offset="0%" stop-color="#3aa6d6"/> + <stop offset="100%" stop-color="#2a3f8a"/> + </linearGradient> + <radialGradient id="ring" cx="50%" cy="50%" r="50%"> + <stop offset="0%" stop-color="#9a6bd6"/> + <stop offset="100%" stop-color="#6a3fae"/> + </radialGradient> + <radialGradient id="galaxy" cx="50%" cy="50%" r="50%"> + <stop offset="0%" stop-color="#e9d6ff"/> + <stop offset="40%" stop-color="#9a72d8"/> + <stop offset="100%" stop-color="#0c1730" stop-opacity="0"/> + </radialGradient> + <radialGradient id="moon" cx="38%" cy="35%" r="70%"> + <stop offset="0%" stop-color="#f2a24a"/> + <stop offset="100%" stop-color="#c56a1f"/> + </radialGradient> + <radialGradient id="teal" cx="38%" cy="35%" r="70%"> + <stop offset="0%" stop-color="#5fd6c0"/> + <stop offset="100%" stop-color="#2a9c86"/> + </radialGradient> + <linearGradient id="comet" x1="0" y1="0" x2="1" y2="0"> + <stop offset="0%" stop-color="#ff8a2a" stop-opacity="0"/> + <stop offset="100%" stop-color="#ffd24a"/> + </linearGradient> + <linearGradient id="hull" x1="0" y1="0" x2="0" y2="1"> + <stop offset="0%" stop-color="#a9743a"/> + <stop offset="100%" stop-color="#6e451d"/> + </linearGradient> + </defs> + + <!-- space backdrop --> + <rect width="1280" height="960" fill="url(#sky)"/> + + <!-- stars + sparkles --> + <g fill="#ffffff"> + <circle cx="90" cy="80" r="2.4"/><circle cx="180" cy="150" r="1.6"/><circle cx="260" cy="60" r="2"/> + <circle cx="360" cy="120" r="1.4"/><circle cx="470" cy="70" r="2.2"/><circle cx="560" cy="140" r="1.5"/> + <circle cx="700" cy="90" r="1.8"/><circle cx="820" cy="150" r="2"/><circle cx="930" cy="70" r="1.5"/> + <circle cx="1040" cy="130" r="2.2"/><circle cx="1150" cy="80" r="1.6"/><circle cx="1220" cy="180" r="2"/> + <circle cx="60" cy="300" r="1.7"/><circle cx="150" cy="420" r="2.1"/><circle cx="40" cy="520" r="1.5"/> + <circle cx="110" cy="640" r="2"/><circle cx="1230" cy="360" r="1.8"/><circle cx="1180" cy="470" r="2.2"/> + <circle cx="1250" cy="600" r="1.6"/><circle cx="1200" cy="720" r="2"/><circle cx="640" cy="40" r="1.4"/> + <circle cx="380" cy="300" r="1.5"/><circle cx="900" cy="320" r="1.6"/><circle cx="1080" cy="260" r="1.5"/> + </g> + <g fill="#ffd24a"> + <path d="M240 240 l4 10 10 4 -10 4 -4 10 -4 -10 -10 -4 10 -4z"/> + <path d="M1000 500 l4 10 10 4 -10 4 -4 10 -4 -10 -10 -4 10 -4z"/> + <path d="M470 470 l3 8 8 3 -8 3 -3 8 -3 -8 -8 -3 8 -3z"/> + <path d="M760 250 l3 8 8 3 -8 3 -3 8 -3 -8 -8 -3 8 -3z"/> + </g> + + <!-- purple ringed planet (top-left) --> + <g transform="translate(150,180)"> + <ellipse cx="0" cy="0" rx="120" ry="34" fill="none" stroke="#d7a24a" stroke-width="10" transform="rotate(-20)"/> + <circle cx="0" cy="0" r="62" fill="url(#ring)" stroke="#0a1226" stroke-width="4"/> + <ellipse cx="0" cy="0" rx="120" ry="34" fill="none" stroke="#d7a24a" stroke-width="10" transform="rotate(-20)" stroke-dasharray="150 400" stroke-dashoffset="150"/> + </g> + + <!-- spiral galaxy (top-right) --> + <g transform="translate(1080,190) rotate(25)"> + <ellipse cx="0" cy="0" rx="130" ry="70" fill="url(#galaxy)"/> + <path d="M0 0 Q60 -30 90 20 Q40 40 0 0" fill="#c9a8ef" opacity="0.7"/> + <path d="M0 0 Q-60 30 -90 -20 Q-40 -40 0 0" fill="#c9a8ef" opacity="0.7"/> + <circle cx="0" cy="0" r="10" fill="#ffffff"/> + </g> + + <!-- teal planet (right) --> + <circle cx="1180" cy="360" r="46" fill="url(#teal)" stroke="#0a1226" stroke-width="4"/> + <ellipse cx="1165" cy="345" rx="16" ry="8" fill="#7fe6d2" opacity="0.6"/> + + <!-- orange cratered moon (bottom-right) --> + <g transform="translate(1130,800)"> + <circle r="90" fill="url(#moon)" stroke="#0a1226" stroke-width="5"/> + <circle cx="-30" cy="-20" r="16" fill="#a85417" opacity="0.6"/> + <circle cx="20" cy="10" r="22" fill="#a85417" opacity="0.5"/> + <circle cx="-10" cy="40" r="12" fill="#a85417" opacity="0.6"/> + <circle cx="40" cy="-30" r="9" fill="#a85417" opacity="0.6"/> + </g> + + <!-- blue asteroid/planet (bottom-left) --> + <circle cx="90" cy="770" r="70" fill="#3f6fae" stroke="#0a1226" stroke-width="5"/> + <circle cx="65" cy="745" r="14" fill="#5f8fce" opacity="0.6"/> + <circle cx="110" cy="800" r="10" fill="#2f579a" opacity="0.6"/> + + <!-- flaming comet (left) --> + <g transform="translate(210,360)"> + <path d="M0 0 L-150 -40 L-150 40 Z" fill="url(#comet)"/> + <circle r="22" fill="#ffce54" stroke="#e07b1f" stroke-width="4"/> + <path d="M-6 -10 q10 -14 20 0 q10 -12 16 4 q-8 8 -20 6 q-14 4 -16 -10z" fill="#ff7a2a"/> + </g> + + <!-- cartoon rocket (right, angled up) --> + <g transform="translate(1000,430) rotate(-30)"> + <path d="M0 -46 q22 22 22 60 h-44 q0 -38 22 -60z" fill="#eef2f7" stroke="#0a1226" stroke-width="4"/> + <circle cx="0" cy="0" r="10" fill="#7fd0ff" stroke="#0a1226" stroke-width="3"/> + <path d="M-22 14 l-18 22 18 -4z" fill="#d64a3a" stroke="#0a1226" stroke-width="3"/> + <path d="M22 14 l18 22 -18 -4z" fill="#d64a3a" stroke="#0a1226" stroke-width="3"/> + <path d="M-10 20 q10 26 20 0z" fill="#ff9a2a"/> + <path d="M-6 24 q6 16 12 0z" fill="#ffd24a"/> + </g> + + <!-- scattered asteroids --> + <g fill="#8a8f9c" stroke="#0a1226" stroke-width="3"> + <path d="M330 640 q-18 -10 -6 -26 q16 -12 30 -2 q16 8 8 26 q-10 16 -32 2z"/> + <path d="M980 700 q-14 -8 -4 -22 q12 -10 24 -2 q12 6 6 22 q-8 12 -26 2z"/> + <path d="M640 820 q-16 -8 -6 -24 q14 -10 28 -2 q14 8 6 24 q-10 14 -28 2z"/> + </g> + + <!-- ===== cosmic sea wave ===== --> + <path d="M0 720 + q120 -60 240 -20 q120 40 240 0 q120 -40 240 0 q120 40 240 -10 q120 -50 240 0 + q40 20 80 10 L1280 960 L0 960 Z" + fill="url(#wave)" stroke="#1a2c66" stroke-width="4"/> + <g fill="#bfe9ff" opacity="0.85"> + <path d="M120 720 q30 -26 60 0 q-30 18 -60 0z"/> + <path d="M420 706 q28 -24 56 0 q-28 18 -56 0z"/> + <path d="M760 712 q30 -26 60 0 q-30 18 -60 0z"/> + <path d="M1040 700 q28 -24 56 0 q-28 18 -56 0z"/> + </g> + <!-- foam swirl --> + <path d="M0 760 q160 40 320 10 q160 -30 320 6 q160 36 320 0 q160 -36 320 8" fill="none" stroke="#dff3ff" stroke-width="6" stroke-linecap="round" opacity="0.8"/> + + <!-- ===== pirate ship ===== --> + <g transform="translate(640,560)"> + <!-- main mast + black skull sail --> + <rect x="-6" y="-260" width="12" height="230" fill="#7a5228" stroke="#0a1226" stroke-width="3"/> + <!-- pennant flag --> + <path d="M6 -258 l70 16 -70 16z" fill="#c0392b" stroke="#0a1226" stroke-width="3"/> + <!-- top small skull flag --> + <g transform="translate(0,-250)"> + <rect x="-2" y="-8" width="4" height="10" fill="#7a5228"/> + </g> + <!-- big black sail --> + <path d="M-150 -230 q150 30 300 0 q-30 130 0 200 q-150 30 -300 0 q30 -110 0 -200z" + fill="#151515" stroke="#0a1226" stroke-width="4"/> + <!-- skull and crossbones --> + <g transform="translate(0,-120)" fill="#f5f5f5"> + <path d="M-60 0 q60 -14 120 0 M-60 20 q60 14 120 0" stroke="#f5f5f5" stroke-width="12" fill="none" stroke-linecap="round"/> + <ellipse cx="0" cy="-8" rx="44" ry="40"/> + <path d="M-30 24 q30 18 60 0 l-6 16 -12 -6 -6 10 -6 -10 -12 6z"/> + <circle cx="-16" cy="-8" r="12" fill="#151515"/> + <circle cx="16" cy="-8" r="12" fill="#151515"/> + <path d="M-4 6 l4 -10 4 10z" fill="#151515"/> + </g> + + <!-- hull --> + <path d="M-200 20 q0 90 200 90 q200 0 200 -90 q-30 26 -200 26 q-170 0 -200 -26z" + fill="url(#hull)" stroke="#0a1226" stroke-width="5"/> + <!-- deck trim --> + <rect x="-200" y="8" width="400" height="18" rx="8" fill="#c69a5a" stroke="#0a1226" stroke-width="4"/> + <!-- portholes --> + <g fill="#3a2410" stroke="#0a1226" stroke-width="3"> + <circle cx="-120" cy="66" r="12"/><circle cx="-60" cy="76" r="12"/> + <circle cx="0" cy="80" r="12"/><circle cx="60" cy="76" r="12"/><circle cx="120" cy="66" r="12"/> + </g> + + <!-- crow's nest with telescope pirate --> + <g transform="translate(150,-190)"> + <path d="M-22 0 h44 l-6 26 h-32z" fill="#8a5f2c" stroke="#0a1226" stroke-width="3"/> + <circle cx="0" cy="-10" r="12" fill="#f0c39a" stroke="#0a1226" stroke-width="2"/> + <path d="M-12 -14 q12 -10 24 0 l-2 -10 -22 0z" fill="#c0392b"/> + <rect x="6" y="-16" width="26" height="6" rx="3" fill="#3a2410" transform="rotate(-20 6 -16)" stroke="#0a1226" stroke-width="2"/> + </g> + + <!-- captain (center, cutlass) --> + <g transform="translate(0,-30)"> + <path d="M-18 0 q18 -8 36 0 l-4 34 h-28z" fill="#c0392b" stroke="#0a1226" stroke-width="3"/> + <circle cx="0" cy="-16" r="17" fill="#f0c39a" stroke="#0a1226" stroke-width="3"/> + <path d="M-20 -22 q20 -18 40 0 q-6 -14 -20 -14 q-14 0 -20 14z" fill="#1b1b1b"/> + <path d="M-8 -20 h6" stroke="#1b1b1b" stroke-width="3"/> + <path d="M4 -20 h6" stroke="#1b1b1b" stroke-width="3"/> + <path d="M-4 -8 q4 4 8 0" stroke="#7a3b1b" stroke-width="3" fill="none"/> + <!-- cutlass --> + <path d="M18 -6 q40 -30 60 -50" stroke="#d9dde3" stroke-width="6" fill="none" stroke-linecap="round"/> + <rect x="14" y="-10" width="10" height="14" rx="3" fill="#c9a25a" stroke="#0a1226" stroke-width="2"/> + </g> + + <!-- pirate with telescope (left, on deck) --> + <g transform="translate(-110,-24)"> + <path d="M-16 0 q16 -6 32 0 l-4 26 h-24z" fill="#2f6fae" stroke="#0a1226" stroke-width="3"/> + <circle cx="0" cy="-14" r="14" fill="#f0c39a" stroke="#0a1226" stroke-width="3"/> + <path d="M-16 -18 q16 -8 30 -2" stroke="#c0392b" stroke-width="8" fill="none" stroke-linecap="round"/> + <rect x="8" y="-18" width="30" height="7" rx="3" fill="#3a2410" transform="rotate(-15 8 -18)" stroke="#0a1226" stroke-width="2"/> + </g> + + <!-- third pirate (right) --> + <g transform="translate(80,-22)"> + <path d="M-14 0 q14 -6 28 0 l-4 24 h-20z" fill="#6a4aa0" stroke="#0a1226" stroke-width="3"/> + <circle cx="0" cy="-14" r="13" fill="#f0c39a" stroke="#0a1226" stroke-width="3"/> + <path d="M-14 -18 q14 -8 28 0 l-2 -9 -24 0z" fill="#c0392b"/> + </g> + + <!-- parrot on the rail --> + <g transform="translate(120,-58)"> + <ellipse cx="0" cy="0" rx="12" ry="16" fill="#2ecc71" stroke="#0a1226" stroke-width="3"/> + <circle cx="0" cy="-14" r="8" fill="#2ecc71" stroke="#0a1226" stroke-width="3"/> + <path d="M6 -16 l12 4 -12 4z" fill="#f1c40f" stroke="#0a1226" stroke-width="2"/> + <circle cx="1" cy="-16" r="2" fill="#0a1226"/> + <path d="M-6 4 q-14 6 -18 20 q10 -6 20 -12z" fill="#e74c3c" stroke="#0a1226" stroke-width="2"/> + <path d="M-4 -2 q-16 4 -20 16 q10 -4 20 -8z" fill="#f39c12" stroke="#0a1226" stroke-width="2"/> + </g> + </g> + + <!-- goofy fish/creature in the water --> + <g transform="translate(830,700)"> + <ellipse cx="0" cy="0" rx="46" ry="30" fill="#e8b84a" stroke="#0a1226" stroke-width="4"/> + <path d="M40 0 l34 -22 v44z" fill="#e8b84a" stroke="#0a1226" stroke-width="4"/> + <circle cx="-18" cy="-8" r="12" fill="#ffffff" stroke="#0a1226" stroke-width="3"/> + <circle cx="6" cy="-8" r="12" fill="#ffffff" stroke="#0a1226" stroke-width="3"/> + <circle cx="-16" cy="-6" r="5" fill="#0a1226"/> + <circle cx="8" cy="-6" r="5" fill="#0a1226"/> + <path d="M-30 14 q18 16 44 0" stroke="#0a1226" stroke-width="4" fill="none" stroke-linecap="round"/> + <path d="M-2 14 q6 22 14 8z" fill="#c0392b" stroke="#0a1226" stroke-width="2"/> + </g> +</svg> diff --git a/ui/static/index.html b/ui/static/index.html new file mode 100644 index 0000000..2e0d786 --- /dev/null +++ b/ui/static/index.html @@ -0,0 +1,92 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>Mediamark — Chart the Kids' Course + + + + + + + +
+
+

Which hold shall we raid?

+

Pick a section, then mark the titles fit for the wee crew.

+
+
+ + + Movies + press m + + + + TV Series + press t + +
+
+ + + + + + + +
+ +
+ + + + +