chore: build runner, gitignore, project manifest, objectives + history docs

This commit is contained in:
Natalie 2026-06-09 05:50:02 -07:00
parent b78a57906a
commit fe53b7cbef
6 changed files with 352 additions and 16 deletions

23
.gitignore vendored
View file

@ -13,20 +13,21 @@ build/
*.xcuserstate
xcuserdata/
# Dependencies (never belongs in git)
node_modules/
# Dependencies + build artifacts of the in-repo subsystems (governor, mcp, search,
# recommender) — the SOURCE of these trees is tracked; only their generated output
# is ignored. (They were formerly separate repos nested + ignored here; now folded
# into this single repo — see .project/history/20260608_*.)
**/node_modules/
**/.venv/
**/__pycache__/
**/.pytest_cache/
recommender/data/
recommender/out/
search/torrents/
**/*.log
# macOS
.DS_Store
# Foreign project trees that physically sit in this directory but belong to their
# OWN repos (portable-net-tv governor, plum-control-mcp, media-recommender, fleet
# design) — never part of the TVAnarchy app repo. Ignored so the auto-commit
# service can't accidentally sweep them into tv-anarchy.
governor/
mcp/
fleet/
recommender/
# Agent session state + git worktrees
.claude/

View file

@ -251,3 +251,108 @@ Discord planes (keep separate):
Watch parties: synchronized LOCAL playback (Discord carries only voice + timestamps), never
stream copyrighted video through Discord.
---
# Networking — two independent planes (added 2026-06-09)
Two VPN/overlay concerns that are **separate systems on separate interfaces** and must
never be conflated or allowed to collide. Plane 1 connects *your own devices to each
other*; plane 2 is *how a public-swarm-facing node leaves the building*.
```
fleet WireGuard (tva0) commercial VPN (.ovpn → tun)
plum ───────────────┐ ┌────────────────────► public swarms
black ───── internal mesh, scoped ──── always-on node ───┤ (only public_swarm_face egress)
phone ───────────────┘ AllowedIPs = fleet subnet only └────────────────────►
every device gets ONE stable fleet IP, home IP stays dark
reachable home OR away (off-home exit)
```
## Plane 1 — Fleet WireGuard (internal mesh): auto-provisioned, tv-anarchy-only, collision-proof
**Why foundational, not stage-4.** Today the app juggles black's home-LAN address
(`10.0.0.11`) vs an overlay address (`10.9.0.4`) and breaks when a device is away
(see [[black-endpoint-lan-vpn]] — the LAN→VPN transmission fallback shipped 2026-06-09
is the *interim* stopgap). A fleet WG fabric gives every device **one stable fleet IP,
reachable home or away**, which collapses `devices.json` to a single IP per device and
removes the juggling entirely. So WG-fabric is promoted to an **early foundational
stage** (before friend-mesh), even though cross-fleet F2F still lands at stage 4.
**Split brain: the package decides, a thin per-platform actuator brings the interface up.**
The fleet package owns all logic — generates each device's Curve25519 keypair (REUSED as
the device's mesh identity, per the anchor-trust model), assigns a stable fleet IP,
distributes the peer list via the broadcast/rendezvous anchor (the public-IP always-on
node), and writes the interface config. Raising the interface is per-OS:
- **Linux** (black, apricot, seedbox): `wg-quick` + a `systemd` unit, root, fully
scriptable end-to-end. Buildable first.
- **macOS** (plum): no kernel WireGuard — wireguard-go over a named `utun`. The app
shells `wg-quick up` **under sudo** (same precedent as the mpv `"sudo": true` path in
`devices.json`); a sandboxed app cannot otherwise raise an interface. The design states
this explicitly rather than assuming `wg-quick up` "just works".
- **iOS** (phone): auto-provisioning is not possible without the WireGuard app + manual
import/MDM. Phone is **manual-config / out of the auto-path** — it's a pure consumer
sink anyway and never holds a duty.
**tv-anarchy-only — a scoped mesh, NOT a VPN.** `AllowedIPs` = the **fleet subnet only**
(never `0.0.0.0/0`), `Table=off`, no default route, no DNS takeover. Only traffic *to
fleet peers* enters the tunnel; the user's general internet and any other VPN are
untouched.
**"Never conflict" is a runtime probe, not a lucky default** (the user said *never*):
at bring-up the actuator enumerates existing routes/interfaces (`netstat -rn` on macOS /
`ip route` + `ip link` on Linux), picks a fleet subnet that overlaps **nothing** present
(then persists it for stability), auto-picks a free UDP listen port (51820 may be the
user's other WG), and uses a dedicated interface name (`tva0`). It refuses to clobber an
existing interface/route and shifts its own subnet instead.
**Single-fleet trust bootstrap is trivial** — the user owns every device, so NO M-of-N
anchor co-signing (that's the cross-fleet stage-4 problem). Keys are minted and trusted
locally within the one fleet.
**Open question (blocks finalizing this plane):** what is `10.9.0.4` today — a
general-purpose VPN/WG the user runs for *other* infra (→ fleet WG is purely additive and
must never route through it), or just an ad-hoc tv-anarchy overlay (→ fleet WG *replaces*
it; a migration)? The answer changes whether plane 1 coexists-with or supersedes the
current overlay.
## Plane 2 — Commercial VPN via uploaded `.ovpn` (the public-swarm exit)
This is the "+ VPN on the public-swarm leg" from the privacy note (line 28) and the
mechanism behind the `public_swarm_face` duty (a node — seedbox FIRST, never a consumer,
prefer `!on_home_ip` — is the only one touching public swarms, keeping the rest of the
fleet dark).
**App setting — VPN configs (import + manage):**
- **Import** individual `.ovpn` files OR a `.zip` bundle. Providers (Mullvad, PIA, AirVPN,
ProtonVPN, …) ship a **zip of per-server `.ovpn` files** (one per location); on zip
import, unpack every `.ovpn` entry (recurse nested folders), ignore non-ovpn files.
- **Parse** each config for: display name (filename, or a `#`-comment / the `remote`
host), `remote` endpoint(s) + `proto` (udp/tcp), and whether certs/keys are **inline**
(`<ca>…</ca>`, `<cert>`, `<key>`, `<tls-auth>`) or **sidecar files** shared across the
bundle (a zip often carries one `ca.crt` + an `auth-user-pass` reference for all
servers). Keep sidecars together with the configs that reference them.
- **Credentials** (`auth-user-pass`): a secure username/password field stored in the
**Keychain**, never written beside the config in plaintext.
- **Manage:** a single managed dir (`~/.config/tv-anarchy/vpn/`); list / rename / group by
provider / delete; pick the active config(s) for the device(s) holding
`public_swarm_face`. Show parsed endpoint/proto/location so the user picks sensibly.
**Actuation (where it runs):** `public_swarm_face` lives on an always-on, ideally
off-home node (seedbox / black — Linux), so OpenVPN bring-up is a Linux concern
(`openvpn` + systemd, root). The egress is **split-routed** — only the torrent client's
public-swarm traffic leaves through the `tun` interface (policy routing / a network
namespace on Linux), so it is **not** a full-device VPN and the box stays reachable on
its normal + fleet interfaces.
## Coexistence — the two planes run simultaneously without collision
A `public_swarm_face` node runs **both** at once: fleet WireGuard on `tva0` (internal
mesh, scoped `AllowedIPs`) AND a commercial-VPN `tun` (public-swarm egress, split-routed).
They occupy different interfaces and different routing scopes:
- fleet-peer traffic → `tva0` (WG)
- public-swarm torrent egress → `tun` (.ovpn)
- everything else → the box's normal route
No interface, subnet, port, or default-route overlap — by the runtime collision-probe
(plane 1) + split-routing (plane 2). This is the structural guarantee behind the user's
"never conflict with other wg or vpn".

183
.project/objectives.md Normal file
View file

@ -0,0 +1,183 @@
# TV Anarchy — Objectives (living backlog)
Running list of agreed objectives. Status: ✅ done (built+tested+staged) · 🟡 in
progress · ⬜ planned · 💭 designed (fleet package, future). Full specs in
`~/.claude/plans/woolly-tumbling-pelican.md`; fleet design in `.project/history/`.
## ✅ Done — built, tested, staged (green)
- **Consolidate** governor/mcp/recommender/search into the single repo; repoint via
`RepoPaths`; in-repo `mcp→search` verified returning live magnets. (Part A)
- **Hosts → Devices** rename + 5 device types — cellphone · laptop · storage · seed ·
**Broadcast Station** — each with an overridable `DeviceServices` set (stream ·
offlineCache · ttlSeed · custody · publicSwarmFace · f2fRelay · meshAnchor),
mirroring the fleet duties; legacy `hosts.json` infers type from kind. (Part B1)
- **Offline cache** — pull next-Y-episodes-of-recent-Z-shows to local disk
(continue-watching / recently-added rules), rsync from black, capped. (Part B2)
- **Media-control forwarding** — Mac media keys / Control Center / lock screen /
AirPods drive the active device + Now Playing; `forwardMediaKeys` setting. (Part B3)
- **Unified playlist** — playing a series episode queues the rest of the show from
there (resume the first, rest from 0). **Continue Watching + Home rails now route
through it too** (was the "S3 didn't queue after S2 Daria" bug — those paths
single-launched one episode and queued nothing).
- **Season 0 = specials/movies**, ordered LAST (Daria's two movies after the run);
shown as "Specials & Movies"; the unified queue puts them at the end.
- **Continue Watching rethink** — was severely broken (one row per *watched episode*,
never advanced, junk `:Zone.Identifier` rows). Now **per-show**, pointing at the
**next** episode (library-ordered → crosses season boundaries, specials last),
most-recent **sustained** play wins (fly-by filter, mirrors the governor), finished
shows drop off, junk filtered.
- **Watch-state core + UI**`WatchState` (unwatched/inProgress/watched) +
`nextUnwatched` + `isWatched`; **watched badges** on the Library grid + the
**thumbnail-tap = "watch next"** / **title-tap = open detail** split (movie plays,
series plays its next unwatched episode and queues the rest). *(Still pending:
rewatch button = reset watch state; searchable top tags; indicators on the other
list formats — Home rails, detail.)*
## ⬜ Planned — specified, not yet built
- **Download manager (Part E):** `TransferHealth` ✅ → smart sort (attention + ETA) ✅
(`downloadingSorted` + "needs attention" count + health pills) → ✅ system+in-app
surfacing (ready-to-watch / stuck) → ✅ **per-torrent debug detail +
reannounce/verify/pause actions** (mcp `tx-detail`/`tx-reannounce`/`tx-verify`/
`tx-start`/`tx-stop`; `TorrentDetail`/`TrackerStat` models; `DownloadsController`
detail+act; "Inspect / fix…" sheet showing error/peers/per-tracker announce +
Reannounce/Verify/Pause-Resume — verified live against black) → ⬜ dead-torrent
re-search swap ("Find a better release": search title → rank by seeders → swap).
- ✅ **Settings UI** — media keys, offline (Y/Z/source), combine + local-LLM, hover
previews, adult, all in the (renamed) **Settings** pane, one-persist on change.
- ✅ **Sidebar subnav** — Library → category links, auto-collapsed (shown only while
Library is the active nav).
- **Search results as collections (Part E):** per-title summary cards (not flat rows),
anchored to library/TMDB; collection page with **resolution selector**
movie = pick resolution → auto-pick best-seeded (one tap); series = **resolution ×
season matrix** showing owned/available/downloading.
- **Search robustness (Part E):** launch-time warmup, per-source partial results,
dependency health row, retry-after-warmup, prune dead sources.
- **Settings hub (Part F):** summary/home page + dedicated pages; everything
toggleable/tuneable via `AppSettings`; **device editing lives in Settings**
(Devices view deep-links to it).
- **Auto-preview pre-warm (Part F):** generate hover previews ahead of time for
likely-previewed content — rules: recently-added · visible-on-screen ·
continue-watching · recommended; capped per pass, low-priority.
- **Watch-state + card interaction (new):**
- **Watched indicators** in every list format (grid/rows/collection) — unwatched /
in-progress / watched.
- **Card tap split:** tapping the **thumbnail** = "**watch next**" (play the next
unwatched episode straight away); tapping the **text/title** area = open the
detail page (breadcrumb). Two targets on one card.
- **Rewatch button** per collection/item — resets watch state so "watch next"
starts over.
- **Searchable top tags** — surface top tags/genres as chips that search on tap.
- **Dynamic show combination (new):** merge the split/duplicate library entries of
one show into a single combined show/collection. Today `mergeSeriesByName` only
merges *exact normalized name + same category + disjoint seasons*, so e.g.
**Dandadan** stays as 34 entries: spacing variants ("DAN DA DAN" vs "DANDADAN"),
cross-category (anime vs cartoons), and duplicate S1 releases (REPACK/Remux/BDRip)
whose overlapping seasons make the merge treat them as different shows. **Rethink:
metadata-anchored grouping** — resolve each entry to a canonical TMDB/enrich work
(title+year+id); same work → combine + dedup episodes per season×episode (alt
releases reachable via the quality switcher); different work → stay separate (so an
anime + its live-action remake don't fuse). Same anchor model as search collections;
feeds the shared catalog's "media definitions."
- ✅ **Cheap stage + seam built:** `ShowGrouping.candidateClusters` blocks variants
by a canonical key (alphanumeric — "DAN DA DAN"/"DANDADAN"/"Dan.Da.Dan" → one
cluster); `ambiguousClusters` is the cheap→reasoner hand-off; the `ShowGrouper`
seam (provider-agnostic: local LLM / TMDB) makes the final same-work call. Two-
stage per the design: **easy algorithm for the bulk + LLM light reasoning on the
ambiguous tail** (the same role the unfilled `TitleRefiner` MLX seam was built for).
- ✅ **Local-LLM reasoner BUILT + wired (no TMDB):** installed **MLX**
(`mlx-lm` + `Qwen2.5-1.5B-Instruct-4bit`) into the recommender uv env — plum now
has a working local LLM. `recommender/media_rec/grouper.py` reasons over a
candidate cluster (correctly groups Dandadan's cross-category dupes); Swift
`LocalLLMGrouper` shells into it like `enrich`; `ShowGroupCache`/
`CachedGroupDecider` persist decisions (once per cluster);
`ShowGrouping.combine`+`merge` apply them (dedup episodes per season×episode, a
year-gap guard against a small model over-merging a remake);
`LibraryController.combineSplitShows()` runs it off-main after load/scan, gated by
the `combineSplitShows` setting.
- ✅ **Shipped default is now deterministic (zero MB), LLM optional.** The measured
1.5B model is 839 MB — too big to ship (no git-lfs; repo .git is 2.5 MB). The
same-work signal is *structural*: `DeterministicGrouper` combines same-canonical-
key entries with close release years (dupes + split seasons) and excludes
year-distant remakes — no model. It's the default in `combineSplitShows`; the MLX
`LocalLLMGrouper` is gated behind `useLLMGrouper` (off) for the rare same-year
ambiguous tail. *Future tiny-ship option:* a Model2Vec static embedding (~30 MB,
CPU-instant) for fuzzy title recall ("Frieren" ≈ "Sousou no Frieren") — beats a
generative model for this task and is genuinely shippable.
- **Sidebar subnav (Part G):** Library → category links, Settings → its pages, with
auto-collapse per nav.
- **Cleanup (Part C, LAST):** remove the stale worktrees + delete the external repos
(plum-control-mcp, portable-net-tv, media-recommender, torrent-search-mcp) once the
port is verified. Nothing irreversible until then.
## 💭 Designed — fleet PACKAGE (future, not app core)
> Keep the mesh *engine* out of `TVAnarchyCore`; the app consumes its outputs. Lives
> in `fleet/` (package).
- **Networking — two planes** (spec: `.project/history/20260608_fleet-manager-mesh-design.md`
→ "Networking — two independent planes"). Separate interfaces, never conflated:
- **Plane 1 — Fleet WireGuard (internal mesh):** auto-provisioned (package mints keys/
subnet/peers, thin per-OS actuator raises the interface — Linux `wg-quick`+systemd;
macOS `wg-quick` under sudo over a utun; iOS manual/out-of-path), **tv-anarchy-only**
(`AllowedIPs`=fleet subnet, `Table=off`, no default route — a scoped mesh, NOT a VPN),
**never-conflict via a runtime route/interface/port collision-probe** + dedicated
`tva0` name. Promoted to an **early foundational stage** because one stable fleet IP
per device (home-or-away) kills today's LAN-vs-VPN juggling ([[black-endpoint-lan-vpn]];
the transmission LAN→VPN fallback is the interim). Single-fleet trust = trivial (own
all devices, no M-of-N). *Open Q: is `10.9.0.4` general infra (additive) or just the
tv-anarchy overlay (migration)?*
- **Plane 2 — Commercial VPN via uploaded `.ovpn` (public-swarm exit):** the
`public_swarm_face` egress that keeps the home IP dark.
- ✅ **Import + manage UI BUILT** (Settings → "VPN (public-swarm exit)"): import
individual `.ovpn` OR a provider **zip** (`ditto` unpack → harvest every `.ovpn`
+ its sidecar certs → group by zip name in `~/.config/tv-anarchy/vpn/`),
`OVPNParser` surfaces remote/proto/needs-login/inline-vs-external-certs,
per-provider login stored in the **Keychain** (`VPNCredentialStore`, never on
disk), list/group/delete. `VPNController` + `VPNConfigStore`; parser/scan/zip
tested (`OVPNParserTests`, 8). Management only — no actuation yet.
- ⬜ **Select active config per `public_swarm_face` device** + **actuation** =
OpenVPN on the always-on Linux node, **split-routed** (only public-swarm torrent
egress through `tun`, not a full-device VPN). Coexists with plane-1 WG on a
separate interface/route scope. (Needs the fleet duty engine.)
- **Mesh registry + duty assignment**`peers_for(infohash)` / `custodians_of(title)`,
auto-built from activity (watch = auto-seed, completion = custody).
- **Shared catalog / "public data drive"** — registry lists + media/collection
definitions (e.g. "The Matrix collection") + optional cover art; **written only by
mesh anchors (Broadcast Stations), read by all, distributed AS a torrent**; a
signed, **versioned** catalog pointer.
- **Anchor trust** — M-of-N existing anchors co-sign a new writer's key (vouching);
reuse WireGuard peer identity; signed revocation list; founder-key bootstrap.
- **Custody floor + reaper** — ≥N copies per wanted title; re-pin before the last
copy vanishes; reaper resurrects dead titles from the mesh first, public re-search
fallback. (System-level "improve health".)
- **Cover-art sharing** across the mesh (a payload of the shared catalog).
- **Friend subscription + mutual promotion** — A invites B, B accepts → a bidirectional,
**signed, revocable** `Friendship` (on the WireGuard-key identity + Discord) carrying
a per-pair share/promote policy: serve each other custody/streaming (bandwidth tier 2),
union each other into `peers_for`, relay each other's F2F requests, and boost each
other's content in discovery/recs. Either side revokes.
- **Anonymized adult sharing** — help friends watch porn you hold *without it being
attributable to you*: serve it **content-addressed (by `ContentID`/infohash, not "my
files")**, **via F2F relay** (source identity stripped hop-by-hop), with a **raised
k-anonymity threshold** (never surfaced below K distinct holders). The friend gets the
bytes by hash; the mesh never reveals *who* has it. A per-`porn` sharing mode on top
of the data boundaries.
- ✅ **Content hashing (`ContentID`) — BUILT.** Canonical, library-agnostic episode id
(`canonicalKey(work)/sNNeNN`, quality opt-in) so the same episode across libraries
(different rips/folders) normalizes to one id; + a SHA-256 short digest that leaks no
title. The keystone for dedup, `peers_for(id)`, k-anonymity counting, and the
anonymized content-addressed serving above. (Byte-exact swarm identity stays the
torrent infohash at the transmission layer.)
- **Data boundaries + bandwidth tiers** ✅ pure models built (`DataDomain`:
tvanarchy/user/publicMedia + shareability; `BandwidthPolicy`: you > friends-when-idle
> public, Travel-Mode = all-to-you). ⬜ Actuation: mcp upload-control verbs
(`transmission-remote -u/-U`, per-torrent), laptop magnet-add for swarm augmentation,
governor enforcement.
## 💭 For later — recommendation engine
- **"Collection ideas like this"** — per-collection "more like this" recommendations
(recommender / media-recommender). Surfaced from a collection item.
## Standing directives
- Keep this list current as objectives are added/finished.
- Keep building while waiting for responses; don't stall on questions.
- Separate core app code from ideas that should be packages.
- Thoroughly test each feature; keep the tree green at every boundary.

View file

@ -1,14 +1,15 @@
#!/usr/bin/env bash
# Build TVAnarchy (Release) and install it to ~/Applications, so the running app
# and the built app can't silently drift. The one irreducible manual step is
# quitting + relaunching — you can't hot-swap a running native app — and the
# sidebar build stamp (v<ver> · <sha> · <time>) makes a stale copy obvious.
# Build TVAnarchy (Release) and install it to /Applications — the standard macOS
# install location (what Finder's "Applications" shows), so the running app and the
# built app can't silently drift. The one irreducible manual step is quitting +
# relaunching — you can't hot-swap a running native app — and the sidebar build stamp
# (v<ver> · <sha> · <time>) makes a stale copy obvious.
set -euo pipefail
cd "$(dirname "$0")"
DD="build/dd"
APP="$DD/Build/Products/Release/TVAnarchy.app"
DEST="$HOME/Applications/TVAnarchy.app"
DEST="/Applications/TVAnarchy.app"
echo "→ stamp build identity (git SHA / time → BuildStamp.swift)"
tools/stamp-build.sh

View file

@ -20,6 +20,9 @@ targets:
type: framework
platform: macOS
sources: [Sources/TVAnarchyCore]
dependencies:
- sdk: MediaPlayer.framework # system transport + Now Playing (NowPlayingController)
- sdk: Security.framework # Keychain for VPN provider logins (VPNCredentialStore)
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: local.lilith.TVAnarchyCore

43
run Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# TVAnarchy task runner. Thin, discoverable wrapper over the repo's build/test
# scripts so the common actions have one obvious name. Add a target by giving it
# a `task::<name>` function below — it shows up in `./run` (no args) automatically.
set -euo pipefail
cd "$(dirname "$0")"
# plum is this MacBook — build a Release app and install it to ~/Applications.
task::update:plum() { ./build-install.sh "$@"; }
# Regenerate the Xcode project from project.yml.
task::generate() { xcodegen generate; }
# Run the unit-test bundle (regenerates the project first so new files are picked
# up). Pass extra args straight through, e.g. `./run test -only-testing:...`.
task::test() {
xcodegen generate >/dev/null
xcodebuild test -project TVAnarchy.xcodeproj -scheme TVAnarchy \
-destination 'platform=macOS' "$@"
}
usage() {
echo "usage: ./run <target> [args]"
echo
echo "targets:"
# List every task::<name> function, stripping the prefix.
declare -F | sed -n 's/^declare -f task::/ /p'
}
main() {
local target="${1:-}"
[ -n "$target" ] || { usage; exit 1; }
shift
if ! declare -F "task::$target" >/dev/null; then
echo "✗ unknown target: $target" >&2
echo >&2
usage >&2
exit 1
fi
"task::$target" "$@"
}
main "$@"