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

11 KiB
Raw Blame History

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 typecellphone | 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.

{
  "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).

{
  "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:

{ "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):

{
  "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. 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.