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>
12 KiB
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.
{
"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.blacktvis legacy and auto-migrated tompv-ipcat load; onlympv-ipc/vlc/quicktimeare offered in the editor.- Command-template tokens:
{query},{season?},{episode?},{path},{releaseId}(aniltemplate = capability absent). - VLC password is not stored here — it's resolved at runtime from the
governor's config or
$VLC_HTTP_PASSWORD(seeVLCConfig). MpvConndecodes 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 (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):
{ "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 (orresumeSeconds >= durationSeconds * 0.92) advance the Continue Watching frontier andplayedPaths."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.92is the single threshold (enforced on write in a few places, on read inisWatchFinishedhelpers).- Readers in the app (
WatchHistory,LibraryController.resumeTargetetc.), 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):
{
"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 aservicespreset 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:
share_policygates the registry merge; flipping private →contenttriggers a consequence-explicit warning naming the tracker.swarm_isolation = f2f_onlyis forced for private sources: content is re-hosted into the fleet's own WireGuard swarm, servedvia: wireguard, never announced to the private tracker. Seedbox sources arecontent+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.