# 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`) **Important architectural split**: Media *management* (acquisition, storage, indexing, policy, preparation) and *playback on the viewer client* (VLC, mpv, QuickTime, VLCKit on iOS, etc.) are two distinct pieces. They are decoupled so you can evolve how content is found/cached/indexed independently of which player backend actually renders it, and support many different "viewer clients" (local laptop VLC vs. black TV mpv vs. phone) against the same managed library. - **Media Management piece** (acquire → store → discover → decide what to prepare): - Torrents/ acquisition: `Torrents/` + `mcp/src/transmission/` + governor fleet bits + search/. - Caching/offline: `OfflineCacheController` (rsync + cull + warmup per device policy), `DownloadsIndex` (filename match for local copies). - Library model + indexing: `Library/` (LibraryController, LibraryScanner from black `index.tsv`, LibraryIndex, LibraryConfig for types/adult, ShowGrouping, Metadata pipeline). - Policies: per-device `offlinePolicy`/`streamPolicy` + `playbackMode` (affects both prep and launch routing). - Background: governor `rsync`/`keeper`/`scan`, black index builder. - UI surfaces: Library tab, Downloads tab, Search, Offline cache panel, Settings offline sections. - **Playback / Viewer Client piece** (control actual rendering + transport on a chosen backend): - Abstraction: `PlayerTarget` + `MediaLaunchable`/`Enqueueable` (in `MediaLaunchable.swift`, `PlayerTarget.swift`). - Orchestration: `PlayerController` (targets from devices.json, poll loop, `enqueuePlaylist`/`launch`, auto-advance logic with client-specific hacks like VLC stop/zero, `checkEpisodeFinished`, progress callbacks, NowPlaying/media keys). - Concrete viewer clients ("Qt, vlc, whatever"): - `VLCTarget` (direct HTTP/Lua to local or configured VLC). - `MpvTarget` + delegated `black-tv` script + Lua hook (for black TV; IPC or script). - `QuickTimeTarget` (AppleScript, single only). - iOS: `VLCPlayerModel` + `VLCMediaPlayer` (embedded VLCKit). - `RokuTarget` (ECP, transport only). - Governor's own: `vlc.ts` `openPlaylist` (spawns VLC --play-and-exit). - UI + per-client: `PlayerView`, `MiniTransport`, `PlaylistController` (turns library episodes into play queues), `HostSelector` (picks the viewer client), display routing. - Client-specific playlist/continuation: app `PlaylistController.fromHere` + enqueue vs. black-tv.sh building m3u siblings vs. governor buffer. - **Glue / crossing the boundary** (this is where the pieces interact without tight coupling): - Watch state: the `watched.jsonl` (single source of truth for resume/continue/played/progress) — *produced* by all playback paths (polls, Lua events, iOS reports, governor keeper, MCP fallbacks), *consumed* by management (cull watched, prefetch next-of-recently-watched, recommender seeds) and UI rails. - Device selection + policy: `devices.json` + active target + `playbackMode`/`offlinePolicy` per device. Playback piece chooses *which* viewer client + whether to demand a local copy; management piece does the actual caching/fetch. - Library data as input to playback: `LibraryController.shows` + `orderedEpisodes` + `continueWatching` (from watch state) feed `PlaylistController` → `enqueuePlaylist`/`launch`. - On-demand prep from playback: `MediaPaths.toStreamURL` + `OfflineCacheController.fetchFile` (and `ensureLocalCopies`) called from launch/enqueue paths when a local viewer client needs bytes. - Facade when app running: `AppLocalAPI` (8791) + `WatchHistoryController` (in-memory derived view of log) make the app the live source for both pieces; MCP proxies through it. - `PlaylistController` sits right on the boundary: consumes pure library data, produces queues for the playback piece. - **v1 code enforcement (DIP)**: `LibraryProviding` protocol (in LibraryModels.swift; LibraryController conforms) — PlayerController etc. depend on the protocol (weak var, attach, static helpers updated), not concrete LibraryController. See PlayerController attach comment and protocol docstring. This was added to v1 as part of executing the plan for best code. This split is visible in the module layout (`Library/` + `Torrents/` + `OfflineCache*` vs. `Player*` + `*Target` + `PlaylistController`), the "two planes" diagram (data plane heavy on management subprocesses, control plane on direct viewer client talk), and how mcp has `transmission/` separate from `vlc/`/`blacktv/`. The multiple "methodologies for playing media" discussed earlier all live inside the **playback / viewer client piece** (different backends + control channels), while sharing the same management backend and the watchlog as the state bridge. ### 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. ### Playback observation, finish detection, and the watch log protocol No player pushes "finished" or live position events to the app. All targets are observed by polling (or host-side scripts), progress/finish are decided locally with a shared `0.92` threshold, and the only durable record is an append-only JSONL watched log read by the entire stack (app, governor, mcp/bridge, black scripts). **VLC (and unified targets) via the Mac app:** - `VLCTarget` (in `Sources/TVAnarchyCore/VLCTarget.swift`) talks to VLC's Lua HTTP interface: `GET /requests/status.json` (Basic auth with password from `VLCConfig` or `$VLC_HTTP_PASSWORD`). Response gives `state`, `time` (pos seconds), `length` (dur seconds), `volume`, `currentplid`, and `information.category` meta (filename/title for matching). - Launch/enqueue/seek/next etc. use the same endpoint with `?command=in_play` (or `in_enqueue`, `seek`, `pl_next`, ...). - `PlayerController.tick()` (called 1.5 s when Player tab / `detailed`, else 10 s) polls the active target (plus others less often), then runs: - `reportLiveProgressIfNeeded()` — throttled (~12 s or on big jump / path change) → `onProgressUpdate(path, pos, dur)` callback. - `checkEpisodeFinished()` — `isEpisodeFinished` (`pos >= dur * 0.92` when known) **or** VLC-specific end case (`!playing && lastNearEndPath == path`). - `updateNearEndTracking()` + `lastNearEndPath` capture the path at `isAtEpisodeEnd` (`pos >= dur-2`); later `!playing` on that path counts as finished because VLC zeros clocks and stops at natural item end (no auto playlist advance for the last/single item). - `checkAutoAdvance()` and `detectAdvance()` correlate the host-reported `title` back to a fired `playbackQueuePaths` entry (exact basename or no-ext match) and only fire `next()` (or auto-enqueue rest of show) after a 3 s grace period stuck at end. - `onItemStarted` fires only for deliberate user-initiated launch/queue (not auto-advance or sleep). - Callbacks are wired in `RootView.swift`: - `onEpisodeFinished` → `library.recordPlay(..., finished: true)` + position at full dur. - `onProgressUpdate` → `recordPosition` (live resume + bars). - `onItemStarted` → initial "resume" marker. - `LibraryController.record*` → `WatchHistoryController` → `WatchHistory` statics that append the JSONL line (client "app"). **The watch log (single source of truth):** - Primary: `~/.local/state/tv-anarchy/watched.jsonl` (plum-side; code in `Sources/TVAnarchyCore/Library/WatchHistory.swift` and `packages/media/src/index.ts`). - Black mirror: `black-watched.jsonl` (populated by `BlackWatchlog.sync()` over SSH + client tag; read in union with the main log). - Event shape (JSONL, one per line): ```jsonc {"ts":"...","event":"play|resume|reset","show":"...","season":1,"episode":3, "label":"...","path":"/bigdisk/.../Show S01E03.mkv", "resumeSeconds":1234,"durationSeconds":2400,"client":"app|governor|bridge|black|mcp", "finished":true} ``` - `event:"play"` = finished (or deliberate start-from-top). `"resume"` = mid watch position update. `"reset"` clears played state for a show (rewatch). - Finish rule (everywhere): explicit `finished` flag **or** `resumeSeconds >= durationSeconds * FINISHED_FRACTION` (const `0.92` in `packages/media`, mirrored by `PlayerController.isEpisodeFinished`, iOS 0.92 check, bridge logic). "play" events advance Continue Watching / playedPaths (after respecting resets); "resume" events supply live `episodeProgress` fractions and resume targets. - Readers: `WatchHistory` (playedPaths, resumePositions, episodeProgress, continueItems), `LibraryController` (continueRail, resumeTarget — with 120 s mid-ep floor), governor `progressPerShow`, mcp bridge `continueWatching` + `resumeFor`, etc. The log is append-only for auditability. **Other writers / observers (all feed the same log):** - **Governor** (`governor/src/keeper.ts`, `vlc.ts`): launchd daemon. Every 30 s calls `currentItem()` (curl VLC HTTP, extract filename from information) and `playbackSnapshot()`. On item change for the profile: push prior item into `state.watched` (delete if policy says), recordWatch `{client:"governor", event:"play", ...}`. Also used for bandwidth reservation. - **Black (mpv, not VLC)**: `mcp/src/blacktv/black-tv-watch.lua` (mpv `--script`). `file-loaded` → append "play" (to black-local `~/.local/state/black-tv/watched.jsonl`). `add_periodic_timer(20s)` + `end-file` + `shutdown` → write last `time-pos` into per-show `resume.json` (read by `black-tv` resume logic). The app mirrors the black log (tagged client "black"). - **iOS + bridge** (`Sources/TVAnarchyiOS/VLCPlayerModel.swift`, `PlayerScreen.swift`, `mcp/src/bridge/watch.ts`): MobileVLCKit `position` / `duration`. Report loop (10 s) + on-stop handler compute 92 % finished flag, POST `/watch/progress` → `recordProgress` → `recordWatch({client:"bridge", finished, ...})`. - **MCP / direct tools**: `mcp/src/vlc/client.ts` + `tools.ts` expose `vlc_status` (returns `time`/`length`/`position`/`state` etc. live) and play/enqueue commands. Watch recording occurs indirectly when media play flows go through bridge or the app's localhost API (port 8791 via `mcp/src/app-bridge/client.ts`). When the Mac app is up it is preferred for live cfg/offline/player state. **Cross-cutting notes:** - `PlaybackStatus` (in `PlayerTarget.swift`) + the common detectors in `PlayerController` (isEpisodeFinished, isAtEpisodeEnd, shouldFireEndOfEpisode, matchPath, resolvedQueuePath, etc.) make the logic target-agnostic (VLC, mpv-IPC, QuickTime, Roku for transport only). - The 0.92 threshold and resume-mid-episode floor are the only "policy" numbers; everything else is observation + append. - Legacy "VLC recents" (macOS plist) integration has been superseded by the governor + jsonl path; current code is purely the shared watched log. - `AppLocalAPI` (8791) lets a running Mac app be the source of truth for MCP without it guessing from logs for non-watch state. This design keeps "where am I in the series?" and "mark watched?" consistent across the Mac app, iOS companion, governor prefetch/cull, black TV viewing, and MCP tool users — even when some pieces are offline. ### 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`** / **`WatchHistoryController`** — the readers and writers for the shared append-only `watched.jsonl` (plum + black mirror). See "Playback observation..." above for the full protocol, 0.92 finish rule, clients, and how "play" vs. "resume" events drive the Continue Watching rail, resume targets, episode progress bars, and played state. (The old direct VLC recents plist path is obsolete.) - **`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 `tv-anarchy-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 `. 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/` (`tv-anarchy-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).