Files
mediamark/README.md
T
unkin-agent c129cb99fc
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Add the initial mediamark app
Single Go binary serving the API and an embedded keyboard-first UI for
promoting fafflix titles into the cheeztv kids tree via hardlinks.

- internal/library: hardlink sync, idempotent re-runs, drift reporting,
  strict single-path-element name validation as the traversal guard
- internal/arr: minimal sonarr/radarr v3 client with a 60s list cache and
  a key-brokered poster proxy
- internal/auth: server-side Authentik group enforcement on every route
- internal/server: library JSON API, art proxy, health probes, SPA
- ui: two-tile landing page, fuzzy-filtered title list, detail panel
- Makefile, Dockerfile, .woodpecker pipelines, pre-commit config
2026-08-29 21:27:09 +10:00

115 lines
5.0 KiB
Markdown

# mediamark
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:
```
<media root>/
fafflix/{movies,tvseries}/<title>/... 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>`.