c129cb99fc
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
115 lines
5.0 KiB
Markdown
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>`.
|