# Operations

How to build, install, configure, and run every component. Schemas referenced here
are detailed in [data-model.md](./data-model.md).

## The app

Distribution is two-tier: **dev builds are local** (built on the build box,
installed locally, never published); **releases are durable** and
published to forge.black (Forgejo), where any node installs/updates from them
without a toolchain or source.

### Daily dev loop (recommended)

Use the `./run` task runner for the common actions (it wraps stamp-build, xcodegen,
xcodebuild, install-via-platform.sh, relaunch, and helper subprojects). See
`./run help` and the script header for the full list and details.

```sh
./run                 # or ./run dev — Debug build + install to Applications + relaunch
./run test
./run clean
./run generate
./run typecheck
./run governor ...
./run mcp
./run bridge          # HTTP bridge for iOS (default :8787)
./run test:all
./run deploy
./run deploy:phone
```

This is the normal iteration loop for macOS (Debug, uses `build/dd` derived data
by default; `TVANARCHY_DD=...` to move it). It installs over the user-visible
copy in /Applications (or ~/Applications) so Spotlight / dock launches the fresh
build, then quits any running instance and re-opens it. The sidebar build stamp
makes it obvious when you're running a fresh one.

### Build & install — dev (the build box, plum)

For a Release-style dev build (no relaunch, matches what `update.sh` will see):

```sh
./build-install.sh
```

Stamps build identity (git SHA / time → `BuildStamp.swift`), runs `xcodegen`,
builds Release into `build/dd`, and installs `TVAnarchy.app` to the
OS-appropriate location: `/Applications` when writable (the standard macOS
location — admin users, no sudo), else `~/Applications` (Apple's per-user
fallback for non-admin accounts). `TVANARCHY_DEST` overrides; non-macOS fails
loud. A stale copy at the other candidate location is removed on install so
two builds can't drift.
The one irreducible manual step is **quit + relaunch** — a running native app
can't be hot-swapped. The sidebar build stamp (`v<ver> · <sha> · <time>`) makes
a stale copy obvious. (Set `TVANARCHY_DD=path` to relocate the derived data;
`tools/release.sh` uses this to build into a throwaway tmp dir.)

### Cut a release → forge.black (plum)

```sh
tools/release.sh [vX.Y.Z]          # tag defaults to v<MARKETING_VERSION>
```

Builds into an ephemeral tmp dir, refuses a dirty tree, tags + pushes over
`forge-wg`, then creates a Forgejo release on `lilith/tv-anarchy` and uploads
the zipped `.app`. Needs a write token: `FORGEJO_TOKEN` env or
`~/.config/tv-anarchy/forgejo-token` (chmod 600) — minted at Forgejo → Settings
→ Applications → Tokens (scope `write:repository`). Bump `MARKETING_VERSION` in
`project.yml` first; the tag must not already exist.

### Install / update — any other node (no toolchain or source)

```sh
tools/update.sh [--force]          # first run installs; later runs no-op if current
```

Pulls the latest release from forge.black (`FORGEJO_API` defaults to the
mesh-stable overlay `http://10.9.0.4:3000`), picks the **asset for this
platform**, compares versions, and swaps it into the **OS-appropriate
destination** (`TVANARCHY_DEST` overrides everywhere):

| OS | Release asset | Destination |
|---|---|---|
| macOS | `TVAnarchy-<tag>.zip` | `/Applications` (admin) else `~/Applications`; Gatekeeper quarantine stripped; old-location copy migrated away |
| Ubuntu (classic Linux) | `TVAnarchy-<tag>-linux-<arch>.tar.gz` | `/opt/tv-anarchy` when `/opt` is writable, else `~/.local/opt/tv-anarchy` |
| Bluefin (immutable/ostree Linux) | same as Linux | always `~/.local/opt/tv-anarchy` (`/usr` is read-only; detected via `/run/ostree-booted`) |
| Windows (Git Bash/MSYS) | `TVAnarchy-<tag>-windows-<arch>.zip` | `%LOCALAPPDATA%\Programs\TVAnarchy` (per-user, no elevation) |
| Android (Termux) | `TVAnarchy-<tag>-android.apk` (all ABIs, no arch suffix) | APK → `~/storage/downloads`, handed to the system package installer via `termux-open` (user confirms the prompt — shells can't install packages on Android by design). Detected before generic Linux (Termux's `uname` says Linux). Version = handoff stamp, since Termux can't query installed packages |
| iOS | — | not via this script (no shell): build the `TVAnarchyiOS` scheme in Xcode onto the device, or TestFlight/sideload |

All per-OS logic (OS detection, destination, asset name) lives in ONE place —
`tools/platform.sh` — sourced by `build-install.sh`, `release.sh`, and
`update.sh`; run standalone (curl-piped, no checkout), `update.sh` fetches that
same file from the forge's raw endpoint, so the logic is never duplicated.

Version compare: macOS reads the bundle plist; Linux/Windows read the
`.release-tag` stamp the script writes on install. A release that lacks this
platform's asset fails loud with the exact missing name and the published list —
**today only the macOS asset is published** (cut on plum); Linux/Windows entries
activate the moment a release carries their assets. A read token is needed only
if the repo is private. Then quit/restart the app.

> Unsigned local build: fine across **your own** Macs (the quarantine strip
> handles Gatekeeper). Distributing to other people's machines would want real
> signing/notarization — a later piece.

### Build manually / in Xcode

Prefer `./run dev` (or the other `./run` targets) for the common flows. For
one-off manual steps:

```sh
brew install xcodegen          # if needed
xcodegen generate              # project.yml → TVAnarchy.xcodeproj (generated, git-ignored)
xcodebuild -scheme TVAnarchy -destination 'platform=macOS' build
# or open TVAnarchy.xcodeproj and Run
```

`project.yml` is the source of truth; the `.xcodeproj` is generated. Local dev is
unsigned (no sandbox/hardened runtime — the app needs `Process`/ssh and
localhost+overlay networking). See `./run help` (or the script source) for the full curated list and one-line
descriptions. Highlights for subprojects (no need to cd + bun manually):

- `./run governor fleet status|duties|...` — runs the governor CLI
- `./run mcp` — the stdio MCP server
- `./run bridge` — the HTTP bridge (iOS remote / torrent control)
- `./run typecheck` — tsc in governor + mcp
- `./run test:all` — Xcode tests + all helper test suites

(You can still run the subprojects directly: `(cd governor && bun run ...)` etc.)

### Config & state locations

| File | Purpose |
|---|---|
| `~/.config/tv-anarchy/devices.json` | Device registry + playback targets (migrates from `hosts.json`, then `~/.config/plumtv/hosts.json`) |
| `~/.local/state/tv-anarchy/settings.json` | App settings (adult gating, previews, media keys, offline cache) |
| `~/.config/portable-net-tv/config.json` | Governor config **and** the VLC HTTP password source |
| `~/.local/state/tv-anarchy/watched.jsonl` (+ `black-watched.jsonl` mirror) | Shared watch log (see architecture.md for protocol, writers, 0.92 rule) |
| `~/.local/state/tv-anarchy/library.json` | Cached library snapshot |
| `~/.local/state/tv-anarchy/meta/` | Metadata sidecars (path-digest keyed) |
| `$VLC_HTTP_PASSWORD` | Alternate VLC password source |

## Playback targets

- **Plum VLC** — enable VLC's HTTP/Lua interface (Preferences → All → Interface →
  Main interfaces → Lua), set the password (read from the governor config or
  `$VLC_HTTP_PASSWORD`). App talks to `http://127.0.0.1:8080/requests/…`.
- **Black (mpv)** — mpv is driven straight to the DRM console (no X) over SSH JSON
  IPC; the `commands` templates delegate launch/library/stats/teardown to
  `/usr/local/bin/black-tv`. Endpoints try LAN (`10.0.0.11`) then the WG overlay
  (`10.9.0.4`) because the LAN address flaps.
- **QuickTime** — local, zero-install; no setup.
- **Roku (ECP)** — transport control of the stick's own playback (play/pause,
  jump-back, exit, now-playing) over its unauthenticated LAN REST on port 8060;
  discoverable via SSDP (`ST: roku:ecp`). Deliberately NOT a library playback
  destination — a Roku can't open NFS paths; that's the planned dev channel
  (see roadmap).

Devices are editable in-app (Devices tab: add/edit/delete, make-active, set
type/services, reload, reset, reveal `devices.json`). The list shows a
per-device system-load badge (low/med/high). Devices with a configured `restart`
command template (black by default) get a **Restart service** menu action that
hard-restarts the host-side player (`black-tv restart`: relaunch the mpv unit,
resuming the live playlist/position; clean teardown when idle/hung).

A **"not up to date"** badge flags a stale helper deploy: the device's `stats`
report carries the sha256 of the helper script it runs (`helper_sha`), and the
app compares it against the repo's vendored copy
(`mcp/src/blacktv/black-tv.sh`) — a mismatch (or a pre-stamp helper that
reports nothing) means the app may be speaking verbs the device doesn't know.
No badge appears when freshness can't be judged (device unreachable, unknown
helper, or no repo checkout).

**Update & restart service** (menu + expanded summary) is the in-app fix: the
app base64-pushes the vendored script over the device's own SSH channel,
installs it atop the deployed bin (`sudo install` — atomic), verifies the
landed sha256, then restarts the player service through the fresh script. The
manual `mcp/README.md` deploy step remains for hosts the app can't reach.

Each row expands (chevron) into a **summary section**: backend + endpoints,
role/fleet class + services, live connection/playback line, host load
(1/5/15-min + mpv decode CPU), the dependent-services line, and the
helper-deployment line (deployed vs
repo hash, judged) — with the Restart / Update & restart buttons inline, so
diagnosing and fixing a wedged device happens in one place.

**Dependent services**: `black-tv stats` also reports a `deps` object —
transmission unit state, mpv unit + socket, media-root presence, DRM display
state, disk used%/free under the media root. The helper sends raw facts;
`HostDeps.facts` (app-side, one testable place) judges severity: transmission
not active / media root missing / disk ≥97% = error, disk ≥90% / TV not
connected / stale socket = warning. The row shows a red/orange pill **only when
something is wrong** (a single issue shows its message, several collapse to a
count); the expanded summary always shows the full facts line with ⚠ on the
interesting ones.

## governor (`portable-net-tv`)

TypeScript/Bun standalone daemon on plum.

```sh
cd governor
bun install
portable-net-tv watch     # daemon: follow VLC, log watches, prefetch next N eps
portable-net-tv next      # print per-show progress
```

Runs as a launchd background agent (Apple Events to VLC are blocked there, which
is why it reads VLC over HTTP, not AppleScript). Config: see
[data-model.md](./data-model.md#configjson--governor-portable-net-tv).

### Fleet engine (`portable-net-tv fleet …`)

```sh
portable-net-tv fleet status            # registry + duties + probed capacity + warnings
portable-net-tv fleet duties            # assign duties; Δ-log changes since last run
portable-net-tv fleet custody           # floor-check every title; print re-pin plans
portable-net-tv fleet repin [--apply]   # execute the re-pin plans (rsync holder→target
                                        #   on the target via ssh; dry-run by default;
                                        #   targets need ssh + mediaRoot in fleet.json)
portable-net-tv fleet reaper [--apply]  # classify healthy|stalled|dead; --apply = safe
                                        #   nudges only (reannounce/verify)
portable-net-tv fleet peers <q>         # peers_for(infohash|title), provenance-tagged
portable-net-tv fleet probe             # ssh df + reachability → rolling capacity state
                                        #   (feeds custody disk-eligibility + status)
portable-net-tv fleet daemon [--apply-nudges] [--interval-min=N]
                                        # periodic tick (default 10 min): duties Δ →
                                        #   floor check → reaper; for launchd
portable-net-tv fleet serve [--port=N] [--token=T]

**Sample launchd plist** (save as `~/Library/LaunchAgents/com.tv-anarchy.fleet-daemon.plist`,
then `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.tv-anarchy.fleet-daemon.plist`):

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.tv-anarchy.fleet-daemon</string>
  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/bin/bun</string>
    <string>run</string>
    <string>/Users/natalie/Code/@applications/tv-anarchy/governor/src/index.ts</string>
    <string>fleet</string>
    <string>daemon</string>
    <string>--apply-nudges</string>
  </array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>WorkingDirectory</key><string>/Users/natalie/Code/@applications/tv-anarchy/governor</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    <key>HOME</key><string>/Users/natalie</string>
  </dict>
  <key>StandardOutPath</key><string>/Users/natalie/.local/state/tv-anarchy/fleet-daemon.log</string>
  <key>StandardErrorPath</key><string>/Users/natalie/.local/state/tv-anarchy/fleet-daemon.log</string>
</dict>
</plist>
```

Edit paths for your install. Use `--interval-min=5` for testing. Logs via Console.app or the file. Safe nudges only; research/repin stay printed plans unless you extend the daemon.

                                        # HTTP service (default :9094, $FLEET_TOKEN):
                                        #   /health /registry /custody /reaper
                                        #   /peers_for/<infohash>[?title=]
```

All subcommands take `--json`. Reads the fleet registry from
`~/.config/tv-anarchy/fleet.json` (array form; `devices.json` fallback) — see
[data-model.md](./data-model.md#fleetjson--the-fleet-registry-app-side--governor-policy).
Read-only by default: mutation only behind `--apply`/`--apply-nudges`
(`repin --apply` rsyncs + records the new holding; reaper nudges are idempotent
transmission ops; mesh recoveries and re-searches remain printed plans).
Engine state under `~/.local/state/tv-anarchy/`: `fleet-state.json` (CLI duty
diffs), `fleet-daemon-state.json` (daemon duty diffs), `fleet-probe-state.json`
(rolling capacity), `fleet-holdings.json` (recorded re-pins).
Tests: `bun test` in `governor/`.

## mcp (`tv-anarchy-mcp`)

TypeScript/Bun. Serves both an MCP stdio server (for Claude) and the CLI bridge
the app shells out to.

```sh
cd mcp
bun install
bun run typecheck
claude mcp add tv-anarchy       # register the MCP server with Claude Code
```

Requirements/constraints:
- macOS only (NSScreen via osascript-jxa; reads `org.videolan.vlc.plist`).
- VLC must be running with the Web interface enabled.
- Torrent search needs **FlareSolverr** on `localhost:8191` (TPB/Nyaa/1337x).
- `media_list_shows` scans `MEDIA_ROOTS` (default `~/media`) for `SxxEyy` files.
- Black-TV watch state is black-local (`/usr/local/share/black-tv/`), read over
  SSH, never written to plum/NFS.

## recommender

Python (managed with `uv`). Invoked by the app's `EnrichService` during
indexing/enrichment; not on the playback path.

```sh
cd recommender
uv run python -m media_rec.enrich "<title>" [year] [--category <cat>]
uv run python recommend_local.py     # keyless local recommendations (preferred)
uv run python recommend.py           # TMDB-backed (needs an API key)
```

Provider routing by category: anime → AniList, tv/cartoons → TVmaze, fallback →
TMDB; degrades gracefully on missing keys/failures. `registry.md` is a generated
snapshot of black's library used as the offline title source.

## Black-side artifacts

- Prebuilt index: `/bigdisk/_/media/_tools/index.tsv` (TSV `size⇥mtime⇥path`),
  fetched by `LibraryIndex` via one SSH `cat`; rebuilt with `nice`/`ionice`
  because black seeds 200+ torrents.
- Registry: black's `.registry.md` dump → `recommender/registry.md`.

## iOS app + bridge

- **Bridge** runs on black as a `systemd --user` unit (`tvanarchy-bridge`,
  port 8787, token auth; env at `~/.config/tvanarchy-bridge/bridge.env`).
  Redeploy from plum with `mcp/deploy/redeploy.sh` — it also syncs
  `~/.config/tv-anarchy/{devices,fleet}.json` to black, which is what the
  Remote tab's `/remote/targets` serves.
- **Phone** installs are dev-signed with a free Personal Team profile that
  **expires every 7 days** — run `tools/deploy-phone.sh` weekly (builds to the
  device so automatic signing can mint the profile, installs via `devicectl`).
- Library scans run in a worker (boot + every 15 min); a request never
  triggers a walk. `?refresh=1` kicks a background rescan and returns the
  current snapshot. /bigdisk is mergerfs (FUSE): per-entry stats are what made
  scans take minutes, so the walk only stats video files — keep it that way.

## Verification after changes

- App: `./run` (or `./run dev`), relaunch if needed, confirm the sidebar build
  stamp (vX · sha · time) updated. Or `./run update:plum` for a Release-style
  build.
- Helpers: `./run typecheck` (or `./run test:all` for full).
- governor/mcp: also `bun run typecheck` / `bun test` directly if iterating inside
  the package.
- recommender: run an `enrich` for a known title and confirm JSON output.
- iOS: `./run deploy:phone` (or `tools/deploy-phone.sh`), then on the phone
  confirm Library loads and a downloaded episode plays in airplane mode.
