net/http's mux answers a literal ../ in the path with a redirect whose exact code differs between Go 1.25 (301) and 1.26 (307), so pinning the code failed CI while passing locally. Assert what the test is actually about: the request is redirected rather than handled, and no kids-tree entry is created.
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
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:
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:
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>.