tv-anarchy/docs/data-model.md
Natalie a86e68c525 feat(apps): add fleet engine mesh core integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 21:23:36 -07:00

253 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Data Model
Two sets: the **config + state schemas in use today** (the app and helpers read
and write these), and the **fleet/mesh data model** (single-fleet core
implemented in the governor's fleet engine; cross-fleet parts still design).
---
## In use today
### `devices.json` — device registry + playback target config
Path: `~/.config/tv-anarchy/devices.json` (auto-migrated forward from
`~/.config/tv-anarchy/hosts.json`, and before that `~/.config/plumtv/hosts.json`).
Source: `Sources/TVAnarchyCore/DeviceConfig.swift`. Written pretty-printed +
sorted-keys; seeded on first run with Plum VLC + Black (mpv) if absent. The
decoder accepts the legacy top-level `hosts` key alongside `devices`.
Each device carries a `type``cellphone | laptop | storage | seed | broadcast`,
mapping to the planned fleet classes `consumer | roamer | server | seedbox |
broadcast` — and an overridable `services` preset (`stream`, `offlineCache`,
`ttlSeed`, `custody`, `publicSwarmFace`, `f2fRelay`, `meshAnchor`). Legacy
entries without a `type` get one inferred from the player backend.
```jsonc
{
"devices": [
{
"id": "plum-vlc",
"name": "Plum VLC",
"kind": "vlc", // vlc | mpv-ipc | quicktime | blacktv(legacy)
"type": "laptop", // cellphone | laptop | storage | seed | broadcast
"services": { "stream": true, "offlineCache": true, "ttlSeed": true },
"vlc": { "host": "127.0.0.1", "port": 8080 }
},
{
"id": "black",
"name": "Black TV",
"kind": "mpv-ipc",
"type": "storage",
"services": { "stream": true, "custody": true },
"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"],
"restart": ["/usr/local/bin/black-tv","restart"]
}
}
]
}
```
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 |
|---|---|---|
| App settings | `~/.local/state/tv-anarchy/settings.json` | `SettingsStore` (adult gating, hover previews, media-key forwarding, offline-cache sizing; tolerant decode) |
| 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 }`.
---
### `fleet.json` — the fleet registry (app-side) + governor policy
Path: `~/.config/tv-anarchy/fleet.json`. The `devices` **array** is the
app-side fleet registry (authoritative for the governor's fleet engine when
present; `devices.json` is the fallback):
```jsonc
{
"devices": [
{
"id": "black", "name": "black",
"deviceClass": "server", // server|roamer|consumer|seedbox|broadcast
"alwaysOn": true, "onHomeIp": true, "reachable": "home_lan",
"duties": ["custody_floor"], // app-side record; the governor recomputes
"services": [
{ "id": "black", "kind": "mpv-ipc", "detail": "lilith@10.0.0.11" },
{ "id": "black-transmission", "kind": "transmission", "detail": "transmission RPC" }
]
}
],
// Optional governor policy keys (read by governor/src/fleet/registry.ts):
"floorCopies": 2, // custody floor (default 2)
"sources": [ // peer sources; gates enforced on load
{ "id": "dht", "kind": "dht" } // implicit when absent
],
"staticHoldings": { "apricot": ["Show Name S01"] } // copies on api-less hosts
}
```
The governor derives from this: `api` (a `transmission` service →
`transmission_rpc`), `ssh`/`addr` (a `user@host` service detail), and capacity
defaults. Engine state (last duty assignment, for change diffs) lives at
`~/.local/state/tv-anarchy/fleet-state.json`. Title-refiner cache:
`~/.local/state/tv-anarchy/title-refinements.json` (filename → refined title,
empties cached too).
---
## Fleet/mesh data model (single-fleet core implemented)
Synthesized from
[`../.project/history/20260608_fleet-manager-mesh-design.md`](../.project/history/20260608_fleet-manager-mesh-design.md).
The single-fleet subset — host registry, duty rules, custody floor, reaper,
source gates, `peers_for`/`custodians_of` — is implemented in
`governor/src/fleet/` (types in `types.ts` mirror the entities below).
Identity/Fleet entities and everything cross-fleet remain design.
### 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).
> **Partially landed:** the app's `devices.json` (above) already carries the
> class mapping (`DeviceType.fleetClass`) and a `services` preset that mirrors
> the duty vocabulary. What does **not** exist is the manager that *assigns*
> duties from capacity/reachability — services today are user-set presets, not
> computed duties.
### 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.