Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
272 lines
12 KiB
Markdown
272 lines
12 KiB
Markdown
# 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 (the watch protocol)
|
||
|
||
Primary path: `~/.local/state/tv-anarchy/watched.jsonl` (plum-side; the app,
|
||
governor, and mcp all append here). Legacy path
|
||
`~/.local/state/tv-anarchy-mcp/watched.jsonl` is auto-migrated on first read
|
||
(`packages/media`). Black TV plays are mirrored from black's
|
||
`~/.local/state/black-tv/watched.jsonl` into a local `black-watched.jsonl` cache
|
||
(by `BlackWatchlog` + `WatchHistoryController.syncBlack`); both files are unioned
|
||
at read time.
|
||
|
||
One JSONL event per line (full shape and finish rule in
|
||
[architecture.md](./architecture.md#playback-observation-finish-detection-and-the-watch-log-protocol)):
|
||
|
||
```jsonc
|
||
{ "ts": "...", "event": "play|resume|reset", "show": "…", "season": 1, "episode": 3,
|
||
"label": "…", "path": "/bigdisk/_/media/.../Show S01E03.mkv",
|
||
"resumeSeconds": 1234, "durationSeconds": 2400, "client": "app|governor|bridge|black",
|
||
"finished": true }
|
||
```
|
||
|
||
- `"play"` events (or `resumeSeconds >= durationSeconds * 0.92`) advance the
|
||
Continue Watching frontier and `playedPaths`.
|
||
- `"resume"` events supply live positions for bars and mid-episode resume targets.
|
||
- `"reset"` clears played state for a rewatch (history is preserved).
|
||
- `client` ("app", "governor", "bridge", "black", "mcp", ...) tags provenance. `FINISHED_FRACTION = 0.92` is the single threshold
|
||
(enforced on write in a few places, on read in `isWatchFinished` helpers).
|
||
- Readers in the app (`WatchHistory`, `LibraryController.resumeTarget` etc.),
|
||
governor, and mcp bridge all derive Continue / resume / badges exclusively from
|
||
this log. No other source of truth.
|
||
|
||
(The obsolete direct "VLC recents" macOS plist integration has been removed.)
|
||
|
||
### 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` |
|
||
| watched.jsonl (watch log) | `~/.local/state/tv-anarchy/watched.jsonl` (+ black mirror) | app, governor, bridge, black mpv Lua (see architecture.md) |
|
||
|
||
### 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.
|