diff --git a/.gitignore b/.gitignore
index 64f8b66..9e6b9cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/.project/history/20260608_fleet-manager-mesh-design.md b/.project/history/20260608_fleet-manager-mesh-design.md
index 06356ca..e5675c6 100644
--- a/.project/history/20260608_fleet-manager-mesh-design.md
+++ b/.project/history/20260608_fleet-manager-mesh-design.md
@@ -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**
+ (`…`, ``, ``, ``) 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".
diff --git a/.project/objectives.md b/.project/objectives.md
new file mode 100644
index 0000000..23b31b7
--- /dev/null
+++ b/.project/objectives.md
@@ -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 3–4 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.
diff --git a/build-install.sh b/build-install.sh
index 0d72005..71e6a09 100755
--- a/build-install.sh
+++ b/build-install.sh
@@ -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 · ·