211 lines
12 KiB
Markdown
211 lines
12 KiB
Markdown
# 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. Plays only files
|
||
downloaded locally (`MediaPaths.toStreamURL` → `file://` of an offline-cache copy)
|
||
— no NFS, and VLC's sftp access is broken on macOS. A not-downloaded item is
|
||
routed to black by `PlayerController.routedTarget(for:)` for that one play.
|
||
- **`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; like VLC it plays
|
||
only locally-downloaded copies (else the item routes to black).
|
||
- **`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 `devices.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`** — primary path parses black's prebuilt index (one SSH `cat`,
|
||
black-side paths kept canonical, no NFS); fallback walks a structured local
|
||
`MEDIA_ROOTS` dir if configured; last resort is `RegistryIngest` (titles from
|
||
`registry.md`, episode-less, offline).
|
||
- **`DownloadsIndex`** — filename index of `OfflineCacheController`'s download dir,
|
||
so a downloaded episode plays on the local player (VLC/QuickTime) while anything
|
||
else routes to black. Refreshed on library refresh + after a cache run.
|
||
- **`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.
|
||
- **`ShowGrouping` / `LocalLLMGrouper` / `ContentID`** — groups episodes into
|
||
shows (with an optional local-LLM pass for messy names) keyed by stable
|
||
content IDs; **`LibraryConfig`** makes the folder→type mapping user-editable
|
||
(types can be added/renamed/removed in Setup; each carries an `adult` flag).
|
||
|
||
### 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). Degenerate (<2-char) regex
|
||
titles are refined by `LocalLLMTitleRefiner` — a subprocess to
|
||
`media_rec/title_refiner.py` (local MLX Qwen, same model as the show grouper),
|
||
disk-cached per filename and self-disabling after consecutive failures so a
|
||
scan never blocks on a missing model. Wired at app startup.
|
||
|
||
### Device registry (Devices tab, `DeviceConfig`)
|
||
|
||
Playback targets generalized into **devices**: each entry in `devices.json` has a
|
||
user-facing `type` (cellphone / laptop / storage / seedbox / broadcast-station)
|
||
that maps onto the planned fleet host classes (consumer / roamer / server /
|
||
seedbox / broadcast) and presets an overridable `services` set (stream,
|
||
offline-cache, TTL-seed, custody, public-swarm-face, F2F-relay, mesh-anchor).
|
||
The Devices list shows a per-device system-load badge (low/med/high, via
|
||
`HostStats`). This is the **app-side registry** for mesh stage 1; the duty
|
||
*assignment engine* (governor-side) is still unbuilt — see
|
||
[roadmap.md](./roadmap.md). Legacy `hosts.json` entries are auto-migrated with
|
||
inferred types.
|
||
|
||
### Adult content (`PornCollectionService`, `AdultView`)
|
||
|
||
The `porn-rotation.py` collection logic ported native. Doubly gated: the
|
||
`ENABLE_ADULT` compile flag and the runtime `pornFeature` setting (off by
|
||
default — the app ships with adult content concealed; a discreet sidebar toggle
|
||
reveals the Adult tab). Which library types count as adult is data, not
|
||
hardcoded: `LibraryConfig` types carry an `adult` flag (the default `porn` type
|
||
has it).
|
||
|
||
### VPN subsystem (`VPN/`)
|
||
|
||
OVPN profile parsing + store (`OVPNProfile`, `VPNConfigStore`), credentials in
|
||
the Keychain (`VPNCredentialStore`, via Security.framework), and a
|
||
`VPNController` with a settings UI — so a roaming device can reach the overlay
|
||
without hand-managed tunnels.
|
||
|
||
### Settings (`SettingsStore`)
|
||
|
||
Small tolerant-decode `settings.json`: adult gating (`pornFeature`,
|
||
`surfaceAdultOnHome`, `switchToAdultOnlyHome`), hover previews, media-key
|
||
forwarding (`NowPlayingController`), and offline-cache sizing
|
||
(`offlineEpisodes`/`offlineShows`).
|
||
|
||
### Persistence locations
|
||
|
||
See [operations.md](./operations.md#config--state-locations) for the full table.
|
||
|
||
## 2b. iOS companion app (`Sources/TVAnarchyiOS`)
|
||
|
||
A separate iOS target (with `Tests/TVAnarchyiOSUITests`): VLCKit-based player
|
||
(`VLCPlayerModel`, `PlayerScreen`), library browsing, on-device downloads
|
||
(`DownloadManager`/`DownloadsView`), and a remote-control surface
|
||
(`RemoteView`). It talks HTTP to a bridge (`BridgeClient`/`BridgeSettings`,
|
||
default port `8787`, optional token) rather than SSH'ing anywhere itself. The
|
||
bridge **server** is not part of this repo's `mcp/` tree — it lives with
|
||
`plum-control-mcp`'s deployment on plum.
|
||
|
||
## 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. It also hosts the **fleet engine**
|
||
(`src/fleet/`) — the implemented single-fleet core of the mesh design:
|
||
- `registry.ts` — joins the app-side fleet registry (`fleet.json`,
|
||
authoritative when present) / `devices.json` (fallback) into `FleetHost`
|
||
records;
|
||
- `duties.ts` — deterministic duty assignment (broadcast / f2f_relay /
|
||
public_swarm_face) with the spec invariants (a consumer never gets a duty;
|
||
home-IP exposure warned);
|
||
- `custody.ts` — N-copy floor-check, rolling-baton custodianship,
|
||
`custodians_of`, re-pin planning;
|
||
- `reaper.ts` — `healthy | stalled | dead` classification + mesh-first
|
||
recovery planning;
|
||
- `peers.ts` — the source model with both private-tracker policy gates and
|
||
`peers_for` (fleet ∪ seedbox ∪ live DHT, provenance-tagged);
|
||
- `transmission.ts` — ssh-tunneled JSON-RPC view of black's daemon.
|
||
CLI: `portable-net-tv fleet status|duties|custody|reaper [--apply]|peers <q>`.
|
||
Read-only by default; `reaper --apply` runs only idempotent
|
||
reannounce/verify nudges. Re-pins and re-sourcing are printed plans —
|
||
cross-host actuation is the remaining stage-1 work.
|
||
- **`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. Mesh layer (single-fleet core implemented; federation designed)
|
||
|
||
The fleet/mesh turns the single-host client into "a private tracker made of your
|
||
friends." The single-fleet core (registry → duties → custody floor → reaper →
|
||
`peers_for`) is implemented in the governor's fleet engine (§3); everything
|
||
cross-fleet below — F2F relay, friend sources, Discord planes — remains design.
|
||
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).
|