docs(@applications): 📝 update architecture and data model docs

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-08 22:07:16 -07:00
parent 92b38b1bae
commit 5b47531b5c
5 changed files with 478 additions and 208 deletions

View file

@ -1,208 +0,0 @@
# tv-anarchy — Gap Analysis (built vs. intended)
**Date:** 2026-06-08
**Scope:** whole vision — the native app, its helper subprocesses (`governor`,
`mcp`, `recommender`), and the self-healing torrent **fleet/mesh** described in
[`20260608_fleet-manager-mesh-design.md`](./20260608_fleet-manager-mesh-design.md).
---
## 1. Summary
The **playback/library app layer is essentially complete and production-grade.**
The native macOS app drives multiple playback targets, browses a cached library,
searches and manages torrents, and enriches metadata — all wired to real UI with
no TODO/FIXME/`fatalError` debt. Its helper subprocesses (`governor`, `mcp`,
`recommender`) are working components.
The **fleet/mesh layer is the gap.** The single largest, most ambitious part of
the design — a self-healing, zombie-resistant torrent mesh with custody floors,
duty assignment, and friend-to-friend federation — exists as a complete 253-line
design spec and a one-paragraph `fleet/README.md`, with **zero implementation.**
Three smaller gaps sit alongside it: the `governor` daemon has not been
generalized into the fleet orchestrator the spec calls for, the MLX title-refiner
is a defined-but-unwired seam, and the Discord notification planes are unbuilt.
---
## 2. Method
Read on 2026-06-08 against the on-disk state of the main checkout
(`/Users/natalie/Code/@applications/tv-anarchy`):
- the design spec `.project/history/20260608_fleet-manager-mesh-design.md` (253 lines);
- the top-level `README.md` and each subsystem README (`governor/`, `mcp/`, `fleet/`, `recommender/registry.md`);
- the Swift sources under `Sources/TVAnarchy` and `Sources/TVAnarchyCore`;
- the `governor/` (TS/Bun), `mcp/` (TS/Bun), and `recommender/` (Python) trees.
**Repo-state caveat (verified, not cosmetic).** The committed `main` tree still
tracks only `Sources` (under the old `PlumTV`/`PlumTVCore` names), `Tests`,
`README.md`, and `project.yml`. The working tree is mid-reorg: it has *deleted*
the `PlumTV*` files and *added*, untracked, the renamed `TVAnarchy*` sources plus
the entire `governor/`, `mcp/`, `recommender/`, `fleet/`, and `tools/` trees.
So the analysis below describes **what exists on disk today**; most of the helper
subsystems and the rename are not yet committed. Committing the reorg is itself a
prerequisite hygiene step, not a feature gap.
---
## 3. Built vs. intended — at a glance
| Subsystem | Intended role | Status | Gap |
|---|---|---|---|
| **App — playback** (`Sources/TVAnarchyCore/*Target.swift`, `PlayerController`) | Pick a target (VLC / mpv-on-black / QuickTime), drive transport | ✅ Built | — |
| **App — library** (`Library/`) | Cached browser over black's index + live NFS scan | ✅ Built | — |
| **App — downloads** (`Torrents/`) | Search + transmission dashboard | ✅ Built | — |
| **App — metadata** (`Metadata/`) | Filename parse → TMDB/IMDb/keyless enrich + artwork | ✅ Built | MLX refiner seam unwired (§4.3) |
| **App — UI** (`Sources/TVAnarchy/*View.swift`) | Home/Player/Library/Search/Downloads/Metadata/Hosts/Logs | ✅ Built | — |
| **governor/** | Today: bandwidth governor + watch/prefetch daemon. Spec: generalize into fleet orchestrator | ⚠️ Partial | Not generalized to duty-assign / reaper / replication-floor (§4.2) |
| **mcp/** | plum-control-mcp: VLC / black-tv / transmission / display tools | ✅ Built | — |
| **recommender/** | TMDB/IMDb/TVmaze/AniList enrichment + local recs | ✅ Built | — |
| **fleet/** | Host registry, duty assignment, `peers_for()`, `custodians_of()`, custody floor, F2F mesh, private-tracker source | ❌ Design-only | Entire subsystem (§4.1) |
| **Discord planes** | Control/ops + QA/community + release announce; content/availability bot | ❌ Design-only | All planes (§4.4) |
| **Multi-identity / cross-fleet** | Single-fleet foundation generalizes to N identities | ❌ Design-only | Nothing multi-identity (§4.5) |
---
## 4. Detailed gaps
### 4.1 `fleet/` — the entire mesh subsystem is unbuilt
**Specced.** The design doc defines a complete system: a host registry
(`Host { class, reachable, always_on, api, capacity, duties[] }`), deterministic
duty assignment (`custody_floor` | `public_swarm_face` | `f2f_relay` | `broadcast`),
a peer-source model (`Source { kind, share_policy, swarm_isolation }`), and two
derived outputs — `peers_for(infohash) → Peer[]` (a holistic meta-tracker) and
`custodians_of(title) → Host[]` (who is obligated to keep a title alive). Zombie
prevention rests on a **custody floor**: watch = auto-seed-with-TTL, an
N-copy replication floor, an always-on backstop per floor, and rolling-baton
custodianship. The spec sequences this into five de-risked stages (see §5).
**Today.** `fleet/` contains only `README.md`, which says verbatim: *"Not yet
implemented. Build order starts at stage 1 (host registry + duty assignment,
single fleet) per the spec."* No registry, no duty logic, no `peers_for`/
`custodians_of`, no source model, no mesh transport.
**Delta.** 100% of the fleet/mesh subsystem. This is the project's defining
feature ("a private tracker made of your friends") and none of it exists in code.
**Consequence.** The product's differentiator is entirely aspirational. Until at
least stage 1 ships, tv-anarchy is a polished single-host media client, not the
mesh it is pitched as.
### 4.2 `governor` not generalized into the fleet orchestrator
**Specced.** The spec wants the governor's bandwidth-arbitration brain extended to
be the mesh orchestrator: run duty-assignment rules deterministically on registry
changes, enforce the replication floor (re-pin when copies drop to N1 before the
last vanishes), and run a **zombie reaper** that classifies every torrent
`healthy | stalled | dead` and, for dead-but-wanted titles, recovers from the mesh
first and falls back to public re-search.
**Today.** `governor/` (TS/Bun, ~2.3k LOC: `governor.ts`, `watch.ts`, `bandwidth.ts`,
`jobs.ts`, `keeper.ts`, …) is a working **single-host** service: it follows VLC
playback, maintains the cross-show watch log, prefetches next-N episodes within a
bandwidth budget, and GCs the buffer. None of the orchestration duties exist.
**Delta.** Duty assignment, replication-floor enforcement, and the zombie reaper.
The bandwidth-budgeting and watch-log foundations are reusable; the orchestration
layer on top is missing.
**Consequence.** Even once `fleet/` defines the registry, nothing *acts* on it.
The governor is the natural home for that actuation and is the gating component
between a static registry and a self-healing mesh.
### 4.3 MLX title-refiner — defined seam, no implementation
**Specced.** README lists a "local (MLX) filename→metadata pipeline" as later-phase
work; `FilenameParser.swift` builds the seam for it.
**Today.** `Sources/TVAnarchyCore/Metadata/FilenameParser.swift` declares the
protocol `TitleRefiner` (line 87) and `public static var refiner: (any TitleRefiner)?`
(line 10), consulted only for the messy tail when regex yields a <2-char title
(line 31). **No type conforms to `TitleRefiner`, no MLX dependency exists, and
`refiner` is never assigned** — grep across `Sources/` finds only the declaration
and the call site. The pipeline is regex-only.
**Delta.** The model-backed refiner implementation. The integration point is
clean and ready.
**Consequence.** Low. Regex handles the vast majority of `SxxEyy` releases;
the refiner only matters for filenames regex can't crack. This is a deliberate
seam, not debt.
### 4.4 Discord planes — unbuilt
**Specced.** The spec separates Discord into distinct planes: control/ops +
QA/community + release announcements on a project-owned home server, and a
content/availability surface that **never** touches the public home server —
it rides the private mesh via a bot inside users' *own* servers. Principle:
*"Discord is a surface you notify, not a backbone you route through."*
**Today.** None of it exists. No bot, no server config, no notify hooks.
**Delta.** All Discord integration.
**Consequence.** Deferred-appropriate. These planes are notification/community
surfaces layered on top of a working mesh; they cannot meaningfully precede §4.1.
### 4.5 Multi-identity / cross-fleet
**Specced.** The "fleet = the atom" model: one Discord identity, typed devices;
the system "works for one user with zero friends; the mesh is the same code with
more identities." Stage 4 federates across fleets via the friend-mesh + F2F relay.
**Today.** Nothing multi-identity exists (it cannot, given §4.1).
**Delta.** Everything beyond a single fleet.
**Consequence.** Correctly last. The single-fleet foundation must exist and be
battle-tested before federation is meaningful.
---
## 5. Sequenced path to close
Following the spec's own de-risked stage order, with the cross-cutting tracks
called out:
1. **Hygiene (prerequisite).** Commit the in-flight reorg: the `PlumTV → TVAnarchy`
rename and the untracked `governor/`, `mcp/`, `recommender/`, `fleet/`, `tools/`
trees. Until this lands, the helper subsystems are not in version control.
2. **Fleet stage 1 — host registry + duty assignment, single fleet** (black + apricot
+ plum + phone). No mesh, no Discord. Unifies what is run by hand today. *(§4.1)*
3. **Governor generalization (parallel track to stages 13).** Wire duty-assignment,
the replication-floor check, and the zombie reaper onto the registry. *(§4.2)*
4. **Fleet stage 2 — seedbox source + `public_swarm_face` duty.** Adds an always-on
custodian; proves the source model on the zero-risk source first.
5. **Fleet stage 3 — `peers_for()` over local sources** (fleet + seedbox + DHT/public).
Meta-tracker, still single-fleet.
6. **Fleet stage 4 — friend-mesh source + F2F relay.** Federates; custody floor goes
cross-fleet. Multi-identity (§4.5) lands here.
7. **Fleet stage 5 — private-tracker source, default-closed.** LAST and
highest-consequence: `swarm_isolation` is **forced** to `f2f_only` and
un-overridable; `content` share is an account-killer, so it ships only once the
F2F isolation path is battle-tested on safe sources.
**Parallel, any time:** the MLX `TitleRefiner` (§4.3) — independent, low-risk.
**After a working mesh:** the Discord planes (§4.4).
---
## 6. Non-gaps / explicitly deferred (not defects)
- **`BlackTVTarget` "missing".** Intentional — the class was retired; `HostKind.blacktv`
is auto-migrated at runtime in `PlayerController.makeTarget()` into an `MpvTarget`
with `CommandsConfig.blackTVDefaults(bin:)`. Black-TV control still works, via mpv.
- **Gift-economy, no ratio enforcement.** Resolved decision (spec, 2026-06-08): ship
gift-economy, build the ratio knob, leave it **off**. Absence of ratio policing is
by design — visibility/social pressure over mechanical bans.
- **No fingerprint-stripping on private shares.** Resolved decision: **warn hard,
strip never** (default). Stripping destroys encode quality; the friend is protected
structurally by `f2f_only` isolation, the source by an explicit warning at the
content-share flip.
- **App doesn't link `governor` / call the MCP server directly.** By design — the app
shells out to the `mcp` CLI bridge (`TorrentService`, `EnrichService`) and to
`recommender` as subprocesses; `governor` runs independently under launchd.
- **Black-TV watch state not on plum.** Intentional — persisted black-local and read
over SSH, never written across the NFS mount.

36
docs/README.md Normal file
View file

@ -0,0 +1,36 @@
# TV Anarchy — Documentation
TV Anarchy is a native macOS (SwiftUI) front end for a home media stack centered
on **plum** (the laptop) and **black** (the media server). It unifies what was
previously CLI/MCP-only: pick a playback target, browse a cached library, search
and manage torrents, and enrich metadata — all from one app. A larger,
forward-looking layer (the self-healing torrent **fleet/mesh**) is designed but
not yet built.
This directory is the project's living documentation. Component-level READMEs stay
next to their code; these documents are the cross-cutting reference.
## Living docs
| Doc | What it covers |
|---|---|
| [architecture.md](./architecture.md) | System shape — the shipped app, its helper subprocesses, and the planned mesh layer |
| [data-model.md](./data-model.md) | Config + state schemas in use today, and the planned fleet/mesh data model |
| [operations.md](./operations.md) | Build, install, run, configure, and deploy every component |
| [roadmap.md](./roadmap.md) | Status (built vs. designed) and the de-risked path to the full vision |
| [glossary.md](./glossary.md) | Domain terms (fleet, custody floor, duty, F2F relay, …) |
## Component docs (next to the code)
- [`../README.md`](../README.md) — the Swift app: targets, layout, build
- [`../governor/README.md`](../governor/README.md) — `portable-net-tv`: watch tracking + prefetch buffer
- [`../mcp/README.md`](../mcp/README.md) — `plum-control-mcp`: VLC / black-tv / transmission / display tools
- [`../fleet/README.md`](../fleet/README.md) — fleet/mesh placeholder (not yet implemented)
- [`../recommender/`](../recommender/) — Python enrichment + recommendations (`recommend_local.py`, `media_rec/`)
## Design source of truth
- [`../.project/history/20260608_fleet-manager-mesh-design.md`](../.project/history/20260608_fleet-manager-mesh-design.md)
— the captured design conversation for the fleet/mesh. The mesh sections of
[architecture.md](./architecture.md) and [data-model.md](./data-model.md)
synthesize it; that file remains the origin record.

130
docs/architecture.md Normal file
View file

@ -0,0 +1,130 @@
# Architecture
Two layers exist: a **shipped media-client layer** (the app + helper subprocesses)
and a **planned mesh layer** (the fleet/self-healing torrent system). Only the
first is implemented; see [roadmap.md](./roadmap.md) for status.
## 1. Design principle: two planes
The app is deliberately split so that no heavy runtime dependency lands in the
native binary:
- **Control plane — native Swift.** Transport (play/pause/seek/volume), target
selection, and UI. Talks to players directly: HTTP to VLC's Lua interface,
JSON IPC over SSH to mpv on black, AppleScript to local QuickTime. Zero new
runtime deps.
- **Data plane — helper subprocesses.** Anything heavy (torrent search,
transmission RPC, metadata enrichment) is shelled out to existing, tested
projects: the `mcp` CLI bridge and the Python `recommender`. The app spawns
them under a login shell so `bun`/`uv` resolve on `PATH`.
```
┌────────────────────────────────────────────┐
│ TV Anarchy.app (Swift) │
│ Sources/TVAnarchy (SwiftUI views) │
│ Sources/TVAnarchyCore (logic, no UI) │
└───────┬───────────────┬──────────────┬──────┘
control plane (direct) │ │ data plane (subprocess)
┌────────────────────────┼───────────┐ │
▼ ▼ ▼ ▼
VLC HTTP/Lua mpv JSON-IPC QuickTime mcp CLI ──▶ transmission RPC
(127.0.0.1:8080) over SSH (AppleScript) (bun) torrent search
to black recommender ──▶ TMDB/IMDb/
(mpv → DRM) (uv/python) TVmaze/AniList
background, independent: governor (portable-net-tv) — launchd daemon on plum
```
## 2. Shipped app (`Sources/TVAnarchyCore`, `Sources/TVAnarchy`)
### Playback targets — the `PlayerTarget` protocol
A single protocol (poll / playPause / resume / setVolume / seek / next / previous
/ stop) with capability sub-protocols (`MediaLaunchable`, `Enqueueable`,
`TrackSelectable`, `QualitySwitchable`, `HostStatsProvider`). Implementations:
- **`VLCTarget`** — HTTP/Lua to plum's VLC. Volume normalized (256 → 100%), track
enumeration + language-preference application, playlist control.
- **`MpvTarget`** — generic mpv over SSH JSON-IPC with request-id batching;
launch/library/stats/teardown are *delegated* to per-host command templates
(`CommandsConfig`). Reports decode `%CPU` for the player chart.
- **`QuickTimeTarget`** — local, zero-install, via AppleScript (local files only).
- **`HostKind.blacktv`** is retired: it is auto-migrated at runtime in
`PlayerController.makeTarget()` into an `MpvTarget` seeded with
`CommandsConfig.blackTVDefaults(bin:)`. Black-TV control still works, via mpv.
### `PlayerController`
`@MainActor`/Observable. Owns target CRUD (persisted to `hosts.json`), a
single-flight poll loop (cadence varies by tab visibility), optimistic command
routing, quality/release switching (refetch only on episode boundary), audio/sub
track selection (persisted per series), a sleep-timer state machine, a transfer
queue, and throttled status-cache persistence.
### Library pipeline (`Library/`)
Cached-first: load a snapshot instantly, refresh in the background, persist.
- **`LibraryScanner`** — fast path parses black's prebuilt index (one SSH `cat`);
fallback walks the `~/media` NFS mount (with autofs-readiness retry); last
resort is `RegistryIngest` (titles from `registry.md`, episode-less, offline).
- **`LibraryIndex`** — fetches/rebuilds black's `index.tsv` with `nice`/`ionice`
(black seeds 200+ torrents); reports determinate progress.
- **`WatchHistory`** — unions the plum watch log
(`~/.local/state/plum-control-mcp/watched.jsonl`) with VLC recents (macOS plist)
to drive the Continue-Watching rail and resume positions.
- **`LibraryController`** — Home rails (Continue / Recently-Added / per-category),
franchise prefix-matching, scan orchestration, launch-request building.
### Downloads pipeline (`Torrents/`)
`TorrentService` shells to the `mcp` CLI for search + transmission RPC;
`DownloadsController` runs an adaptive-cadence transmission dashboard and fires a
completion callback so finished folders get incrementally indexed.
### Metadata pipeline (`Metadata/`)
`FilenameParser` (regex: title/year/SxxEyy/quality/codec/source) →
`EnrichService` (subprocess to `recommender`, provider routed by category) →
`MetaWriter` (path-digest `.meta` sidecars, best-effort black mirror) and
`ArtworkService` (ffmpeg frame-grab fallback). An MLX `TitleRefiner` seam exists
but is unwired (see [roadmap.md](./roadmap.md)).
### Persistence locations
See [operations.md](./operations.md#config--state-locations) for the full table.
## 3. Helper subprocesses
- **`governor/` (`portable-net-tv`, TS/Bun).** A standalone launchd daemon on
plum: follows VLC playback, appends to the shared watch log, prefetches the
next *N* episodes within a bandwidth budget, and GCs the buffer. The app does
**not** invoke it; it runs on its own. In the mesh design this same
bandwidth-arbitration brain is the intended fleet orchestrator (not yet built).
- **`mcp/` (`plum-control-mcp`, TS/Bun).** An MCP stdio server *and* a CLI bridge.
The app uses the CLI (`TorrentService`, `EnrichService`); MCP clients (Claude)
use the server. Domains: VLC, black-tv (SSH→mpv on DRM console), transmission
(search needs FlareSolverr), display.
- **`recommender/` (Python).** Title→metadata resolution
(TMDB/IMDb/TVmaze/AniList, routed by category) and local recommendations
(`recommend_local.py`, keyless). Invoked only during indexing/enrichment.
## 4. Planned mesh layer (designed, unbuilt)
The fleet/mesh turns the single-host client into "a private tracker made of your
friends." Two graphs ride one Discord identity layer:
- **Custody graph** — narrow, trust-bounded (1° friends + always-on nodes). Holds
the seeder floor; the zombie-prevention guarantee lives here.
- **Discovery/signal graph** — wide, six-degrees, anonymized + per-fleet-deduped.
Carries popularity signal and relays friend-to-friend (F2F) requests.
The unit is the **fleet** (one identity, typed devices), not a person or device —
so the system works for a single user with zero friends and the mesh is the same
code with more identities. A **broadcast** node (seedbox/vps-0) anchors F2F
rendezvous, holds the aggregated peer registry, runs the Discord bridge, and is
optionally the only node touching public swarms (keeping home connections dark).
The **governor** (generalized) assigns duties and enforces the custody floor /
zombie reaper. Full entities, duty-assignment rules, and the peer-source policy
are in [data-model.md](./data-model.md#planned-fleetmesh-data-model); the staged
build order is in [roadmap.md](./roadmap.md).

192
docs/data-model.md Normal file
View file

@ -0,0 +1,192 @@
# Data Model
Two sets: the **config + state schemas in use today** (the app and helpers read
and write these), and the **planned fleet/mesh data model** (designed, unbuilt).
---
## In use today
### `hosts.json` — playback target config
Path: `~/.config/tv-anarchy/hosts.json` (auto-migrated forward from the pre-rename
`~/.config/plumtv/hosts.json`). Source: `Sources/TVAnarchyCore/HostConfig.swift`.
Written pretty-printed + sorted-keys; seeded on first run with Plum VLC + Black
(mpv) if absent.
```jsonc
{
"hosts": [
{
"id": "plum-vlc",
"name": "Plum VLC",
"kind": "vlc", // vlc | mpv-ipc | quicktime | blacktv(legacy)
"vlc": { "host": "127.0.0.1", "port": 8080 }
},
{
"id": "black",
"name": "Black TV",
"kind": "mpv-ipc",
"mpv": {
"endpoints": ["lilith@10.0.0.11", "lilith@10.9.0.4"], // LAN first, WG overlay fallback
"socket": "/tmp/mpv.sock", // default
"sudo": true, // root-owned socket → sudo socat
"socat": "socat", // default
"volumeScale": 130 // mpv --volume-max
},
"commands": { // argv templates for what IPC can't do
"launchShow": ["/usr/local/bin/black-tv","play-show","{query}","{season?}","{episode?}"],
"launchResume": ["/usr/local/bin/black-tv","resume-show","{query}"],
"launchFile": ["/usr/local/bin/black-tv","play","{path}"],
"releases": ["/usr/local/bin/black-tv","releases"],
"resolveRelease": ["/usr/local/bin/black-tv","resolve-release","{releaseId}"],
"stats": ["/usr/local/bin/black-tv","stats"],
"stop": ["/usr/local/bin/black-tv","stop"]
}
}
]
}
```
Notes:
- `kind`: `vlc`, `mpv-ipc`, `quicktime`. `blacktv` is legacy and auto-migrated to
`mpv-ipc` at load; only `mpv-ipc`/`vlc`/`quicktime` are offered in the editor.
- Command-template tokens: `{query}`, `{season?}`, `{episode?}`, `{path}`,
`{releaseId}` (a `nil` template = capability absent).
- VLC password is **not** stored here — it's resolved at runtime from the
governor's config or `$VLC_HTTP_PASSWORD` (see `VLCConfig`).
- `MpvConn` decodes from a minimal `{ "endpoints": [...] }`; every other field
has a default.
### `config.json` — governor (`portable-net-tv`)
Path: `~/.config/portable-net-tv/config.json` (source: `governor/README.md`).
```jsonc
{
"vlcHttp": { "host": "127.0.0.1", "port": 8080, "password": "<password>" },
"buffer": { "dir": "/Users/natalie/Movies/net-tv-buffer", "ahead": 3, "minFreeGB": 2 }
}
```
`buffer` is optional (defaults: `~/Movies/net-tv-buffer`, `ahead` 3, `minFreeGB` 2).
### Watch log — append-only JSONL
Path: `~/.local/state/plum-control-mcp/watched.jsonl` (shared by the governor and
`plum-control-mcp`; read by the app's `WatchHistory`). One event per line:
```jsonc
{ "ts": 1717800000, "event": "play", "show": "…", "season": 1, "episode": 3,
"label": "…", "path": "…", "resumeSeconds": 920 }
```
Only `play`/`resume` events count toward progress. Show name is parsed from the
filename (everything before the `SxxEyy` marker).
### App-local cache + state
| Artifact | Path | Writer |
|---|---|---|
| Library snapshot | `~/.local/state/tv-anarchy/library.json` | `LibraryStore` |
| Metadata sidecar | `~/.local/state/tv-anarchy/meta/<sha256(path)>.json` | `MetaWriter` (mirrored best-effort to `<remotePath>.meta` on black) |
| Artwork cache | frame-grab JPEGs (keyed by path) | `ArtworkService` |
| VLC recents | macOS `org.videolan.vlc` plist (read-only) | VLC |
### Library models (in-memory / snapshot)
`CachedShow { name, rootDir, category, kind(series|movie), posterPath, overview,
episodes[], year, seasonCount, episodeCount, addedAt }`,
`CachedEpisode { path, season, episode, label, metaPath }`,
`LibrarySnapshot { shows[], capturedAt, source(scan|registry) }`,
`ContinueItem { title, path, show, season, episode, positionSeconds, lastSeen,
source(watchlog|vlc), posterPath }`. Decoders are tolerant so old snapshots load.
`ParsedFilename { title, year, season, episode, quality, codec, releaseSource }`,
`MediaMeta { path, parsed, resolvedTitle, mediaType, overview, posterURL,
ratings, genres, enrichedAt }`.
---
## Planned fleet/mesh data model
Synthesized from
[`../.project/history/20260608_fleet-manager-mesh-design.md`](../.project/history/20260608_fleet-manager-mesh-design.md).
**None of this is implemented.**
### Entities
```
Identity { discord_id, display_name, fleets[] }
Fleet { id, identity, hosts[], sources[], custody_capacity }
Host { ...registry, below... }
Source { ...peer-source, below... }
```
`custody_capacity = Σ over always_on hosts of (disk_free × uptime_score)` — whether
a fleet can hold a floor or is a net consumer. Gift-economy mode ignores it for
prioritization; ratio mode (off by default) weights by it.
### Host registry
```
Host {
id, fleet_id
class: server | roamer | consumer | seedbox | broadcast // what it IS
reachable: home_lan | wireguard | public_ip
always_on: bool
on_home_ip: bool // true = public-swarm traffic exposes the home connection
api: transmission_rpc | qbittorrent | utorrent_web | none
capacity: { disk_free, up_bw, uptime_score(∈[0,1], rolling) }
duties: Duty[] // assigned by the manager, never hardcoded — what it DOES
}
Duty = custody_floor | public_swarm_face | f2f_relay | broadcast
```
Example fleet: black = server, apricot = secondary always-on, plum = roamer (TTL
seeder), phone = consumer (pure sink, never any duty).
### Duty-assignment rules (deterministic, run on registry change)
| Duty | Eligibility | Rule |
|---|---|---|
| `broadcast` | `public_ip && always_on` | exactly ONE per fleet; prefer seedbox > vps-0 > server |
| `f2f_relay` | `always_on && reachable ∈ {wireguard, public_ip}` | broadcast host + any other always-on server |
| `public_swarm_face` | prefer `!on_home_ip` | seedbox FIRST; never a consumer; never on_home_ip if an off-home option exists |
| `custody_floor` | `always_on && disk_free > title_size` | N most-recent eligible holders; ≥1 slot reserved for an always-on node |
Invariants: a `consumer` never receives a duty (checked first); `public_swarm_face`
prefers off-home-IP so home stays dark; every active title's floor keeps ≥1
always-on holder.
### Peer-source model
```
Source {
id, fleet_id
kind: dht | public_tracker | private_tracker | friend_mesh | fleet_host | seedbox
api/creds: <opaque, encrypted at rest>
share_policy: search_only | content // private_tracker DEFAULT-CLOSED (search_only)
swarm_isolation: f2f_only | open // private_tracker FORCED to f2f_only (un-overridable)
}
```
Two policy gates are load-bearing because a private `.torrent` carries an embedded
passkey — a friend announcing it to the private tracker gets the **source** user
banned:
1. `share_policy` gates the registry merge; flipping private → `content` triggers a
consequence-explicit warning naming the tracker.
2. `swarm_isolation = f2f_only` is forced for private sources: content is re-hosted
into the fleet's own WireGuard swarm, served `via: wireguard`, never announced
to the private tracker. Seedbox sources are `content` + `open` (no gates).
### Derived outputs (the point of everything above)
```
peers_for(infohash) → Peer[] // holistic seeder list / user-owned meta-tracker
custodians_of(title) → Host[] // who is obligated to keep it alive
Peer { addr, source_kind, source_id, served_via: public | wireguard }
```
`peers_for` unions privates fleet friends' fleets seedbox DHT/public,
provenance-tagged so the UI can show *why* a peer exists and over what transport.

120
docs/operations.md Normal file
View file

@ -0,0 +1,120 @@
# Operations
How to build, install, configure, and run every component. Schemas referenced here
are detailed in [data-model.md](./data-model.md).
## The app
### Build & install (recommended)
```sh
./build-install.sh
```
Stamps build identity (git SHA / time → `BuildStamp.swift`), runs `xcodegen`,
builds Release, and copies `TVAnarchy.app` to `~/Applications`. 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.
### Build manually / in Xcode
```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).
### Config & state locations
| File | Purpose |
|---|---|
| `~/.config/tv-anarchy/hosts.json` | Playback targets (migrates from `~/.config/plumtv/hosts.json`) |
| `~/.config/portable-net-tv/config.json` | Governor config **and** the VLC HTTP password source |
| `~/.local/state/plum-control-mcp/watched.jsonl` | Shared watch log |
| `~/.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.
Hosts are editable in-app (Hosts tab: add/edit/delete, make-active, reload, reset,
reveal `hosts.json`).
## 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).
## mcp (`plum-control-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 plum-control # 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`.
## Verification after changes
- App: `./build-install.sh`, relaunch, confirm the build stamp updated.
- governor/mcp: `bun run typecheck` in each package.
- recommender: run an `enrich` for a known title and confirm JSON output.