feat(apps): add fleet engine mesh core integration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-09 21:23:36 -07:00
parent 7ff780fe56
commit a86e68c525
13 changed files with 399 additions and 19 deletions

View file

@ -0,0 +1,95 @@
# Handoff — fleet engine (mesh stage 1+3 core) + MLX TitleRefiner
Session date: 2026-06-09 (evening). Scope: "do everything left on the roadmap
that is buildable from this repo." Everything below is committed on `main`
(swept into autocommits `8f12f47` "local llm title refiner integration" and
`7ff780f` "restart command support" — the autocommit daemon mixes sessions'
files; the commit titles do NOT map cleanly to this work).
## What landed
### 1. Fleet engine — `governor/src/fleet/` (TS/Bun)
The implemented single-fleet core of the mesh design spec
(`../history/20260608_fleet-manager-mesh-design.md`):
| File | Owns |
|---|---|
| `types.ts` | FleetHost / Duty / Source / Peer / Holding / verdict types (mirror the spec entities) |
| `registry.ts` | ingest: `~/.config/tv-anarchy/fleet.json` **array form is authoritative** (the app-side fleet registry written by the Devices-tab session — knows apricot/phone); `devices.json` fallback; optional policy keys `floorCopies` / `sources` / `staticHoldings` / object-form `devices` overrides |
| `duties.ts` | deterministic duty assignment (broadcast / f2f_relay / public_swarm_face) + invariants (consumer never gets a duty; one broadcast; seedbox-first face; home-IP exposure warned); `diffDuties` for change logs |
| `custody.ts` | N-copy floor-check (default 2), rolling-baton custodianship, ≥1 always-on slot, `custodians_of`, re-pin **plans** |
| `reaper.ts` | healthy\|stalled\|dead (idle 30 min / 72 h), mesh-first recovery upgrade, re-search fallback |
| `peers.ts` | source model with BOTH private-tracker gates (`search_only` default-closed; `f2f_only` FORCED un-overridable) + `peers_for` (fleet seedbox live DHT, provenance-tagged, deduped) |
| `transmission.ts` | ssh→localhost:9091 JSON-RPC view of black's daemon (vitals, holdings, live peers, reannounce/verify/start) |
| `cli.ts` | `portable-net-tv fleet status\|duties\|custody\|reaper [--apply]\|peers <q>` (all `--json`); state diff at `~/.local/state/tv-anarchy/fleet-state.json` |
Read-only by default. The ONLY mutating path is `fleet reaper --apply` =
idempotent transmission nudges. Re-pins / mesh recoveries / re-searches are
printed plans by design.
### 2. MLX TitleRefiner — seam closed
- `recommender/media_rec/title_refiner.py` — MLX Qwen2.5-1.5B (same model as
the grouper), prompt → `{"title": ...}`, plausibility guard (must share a
token with the filename), degrades to empty title without MLX.
- `Sources/TVAnarchyCore/Metadata/LocalLLMTitleRefiner.swift` — shells uv like
`LocalLLMGrouper`; disk cache `~/.local/state/tv-anarchy/title-refinements.json`
(empties cached too); 2-consecutive-failure session kill-switch.
- Wired in `TVAnarchyApp.init()`.
- **Bug fixed en route:** `FilenameParser.extractTitle` fell back to the raw
filename BEFORE the `<2 chars → refiner` check, so the refiner was
unreachable. Fallback now runs after the consult.
### 3. Docs aligned
`docs/roadmap.md` (status table + build order rewritten), `docs/architecture.md`
(§3 fleet engine, §2 refiner, §4 retitled), `docs/data-model.md` (fleet.json
schema as found on disk), `docs/operations.md` (fleet CLI section),
`fleet/README.md` (points at the implementation).
## Verification (all green at handoff)
- `cd governor && bun test`**45 pass** (5 files: duties/custody/reaper/peers/registry)
- `cd governor && bunx tsc --noEmit` → clean
- `xcodebuild -scheme TVAnarchy test`**149 pass** (incl. 2 new refiner-seam tests)
- Live: `fleet status` (4-member registry from real fleet.json), `fleet reaper`
against black = 218 torrents → 180 healthy / 32 stalled / 6 dead,
`fleet custody` = every title breaches 2-copy floor (true: black is the only
custodian), `title_refiner.py "[Anime Time] Sousou no Frieren - …"`
`"Sousou no Frieren"` on real MLX.
## What's NOT done (and why)
1. **Re-pin actuation** — executing cross-host copies + auto-feeding `research`
actions into `search/`. Plans print today. This is the next real step;
build it in governor (transfer queue territory), never in the Swift app.
2. **Fleet WG fabric (plane 1)** — BLOCKED on an open user decision: is
`10.9.0.4` a general overlay (fleet WG additive) or ad-hoc tv-anarchy
(fleet WG replaces)? Also needs root on each node. Spec section
"Networking — two independent planes."
3. **Seedbox / friend-mesh / private-tracker / Discord** — blocked on external
infrastructure (a provisioned box, other fleets, creds, bot tokens). The
engine already models seedbox class/duties and enforces the private gates.
## Gotchas for the next session
- **Autocommit daemon**: your working tree WILL be committed under another
session's message mid-flight. Check `git log` before assuming anything is
uncommitted; don't be surprised when your diff vanishes.
- **fleet.json is app-owned** (array form, written by the Devices-tab work in
a different worktree — no writer exists in THIS tree's Sources). The governor
only reads it. Policy keys (floorCopies/sources/staticHoldings) are additive
top-level keys; if the app-side writer ever rewrites the file wholesale,
check it preserves unknown keys.
- **fleet.json says `reachable: home_lan` for black/apricot**, so no f2f_relay
duty is assigned (needs wireguard|public_ip). The engine is correct; the
registry data is conservative. Flip `reachable` to `wireguard` (or override
in object form) to see relay duties.
- **Swift test suite writes the real `~/.config/tv-anarchy/devices.json`**
(loadOrSeed) — back it up before config/migration verification.
- Reaper thresholds: STALL_AFTER 30 min, DEAD_AFTER 72 h idle (incomplete +
peerless). Complete torrents are always healthy.
- transmissionHost = first registry host with `transmission_rpc` AND an ssh
destination — apricot advertises transmission but has no `user@host` service
detail, so black is the working host today.

View file

@ -0,0 +1,11 @@
# .project/handoffs/
Session-to-session handoff notes. Multiple Claude sessions work this checkout
concurrently (and an autocommit daemon sweeps the working tree, so one
session's commits routinely contain another session's files) — these notes are
how a session tells the next one what it changed, what it verified, and what it
deliberately left undone.
Convention: one file per handoff, `YYYYMMDD_topic.md`, newest facts win.
Long-form design conversations go in `../history/`; handoffs are operational:
**what landed, how it was verified, what's next, what will bite you.**

View file

@ -11,6 +11,8 @@ struct DevicesView: View {
@State private var editing: DeviceConfig? // edit sheet (existing device)
@State private var adding = false // add sheet
@State private var confirmReset = false
@State private var restarting: Set<String> = [] // device ids with a restart in flight
@State private var restartNote: String?
private var devices: [DeviceConfig] { controller.editableDevices }
@ -51,10 +53,18 @@ struct DevicesView: View {
.help("Cache the next episodes of your recent shows to this device")
}
if let s = controller.hostStatsByID[d.id] { loadPill(s) }
if restarting.contains(d.id) {
ProgressView().controlSize(.small)
.help("Restarting the player service…")
}
Text(stateLabel(snap.state)).font(.caption).foregroundStyle(color(snap.state))
Menu {
Button("Make active") { controller.setActive(d.id) }
.disabled(d.id == controller.activeID || !d.services.stream)
if controller.canRestartService(d.id) {
Button("Restart service") { restartService(d) }
.disabled(restarting.contains(d.id))
}
Button("Edit…") { editing = d }
Button("Delete", role: .destructive) { controller.deleteDevice(d.id) }
.disabled(devices.count <= 1)
@ -66,6 +76,9 @@ struct DevicesView: View {
if let s = offline.status {
Text(s).font(.caption).foregroundStyle(.secondary)
}
if let restartNote {
Text(restartNote).font(.caption).foregroundStyle(.secondary)
}
HStack {
Button("Reload config") { controller.reload() }
@ -92,6 +105,18 @@ struct DevicesView: View {
}
}
/// Restart the device's host-side player service (e.g. black's mpv unit) and
/// surface the outcome inline; the row spins while the restart is in flight.
private func restartService(_ d: DeviceConfig) {
restarting.insert(d.id)
restartNote = "Restarting \(d.name)"
Task {
let ok = await controller.restartService(d.id)
restarting.remove(d.id)
restartNote = ok ? "\(d.name): service restarted" : "\(d.name): service restart failed"
}
}
/// Compact "stream · offline · seed · custody" summary of the on services.
private func servicesSummary(_ s: DeviceServices) -> String {
var on: [String] = []

View file

@ -188,6 +188,24 @@ public struct CommandsConfig: Codable, Sendable, Equatable {
self.restart = restart
}
enum CodingKeys: String, CodingKey {
case launchFile, releases, resolveRelease, stats, stop, restart
}
/// Tolerant decode: a pre-`restart` config whose teardown is the canonical
/// `[<helper>, "stop"]` gets `restart` delegated to the same helper no
/// migration step, same pattern as the legacy type/services inference. Any
/// other stop shape leaves the capability absent.
public init(from d: Decoder) throws {
let c = try d.container(keyedBy: CodingKeys.self)
launchFile = try c.decodeIfPresent([String].self, forKey: .launchFile)
releases = try c.decodeIfPresent([String].self, forKey: .releases)
resolveRelease = try c.decodeIfPresent([String].self, forKey: .resolveRelease)
stats = try c.decodeIfPresent([String].self, forKey: .stats)
stop = try c.decodeIfPresent([String].self, forKey: .stop)
restart = try c.decodeIfPresent([String].self, forKey: .restart)
?? stop.flatMap { $0.count == 2 && $0[1] == "stop" ? [$0[0], "restart"] : nil }
}
/// The delegated commands for a `black-tv` helper at `bin` the seed default
/// and the legacy-config migration target.
public static func blackTVDefaults(bin: String) -> CommandsConfig {

View file

@ -11,7 +11,7 @@ import Foundation
/// so a whole status poll is a single round-trip. Responses are matched by
/// `request_id` (mpv echoes it), never by position, so interleaved async event
/// lines don't corrupt the parse.
public final class MpvTarget: PlayerTarget, QualitySwitchable, HostStatsProvider, MediaLaunchable, Enqueueable, TrackSelectable {
public final class MpvTarget: PlayerTarget, QualitySwitchable, HostStatsProvider, MediaLaunchable, Enqueueable, TrackSelectable, ServiceRestartable {
public let id: String
public let name: String
public let kind: HostKind = .mpvIPC
@ -59,6 +59,15 @@ public final class MpvTarget: PlayerTarget, QualitySwitchable, HostStatsProvider
/// goes through the host's configured `stop` command for identical cleanup.
public func stop() async { await runCommand(commands?.stop, [:]) }
// MARK: ServiceRestartable (delegated)
public var canRestartService: Bool { commands?.restart != nil }
/// Hard-restart the host's player service (black: relaunch the mpv unit,
/// resuming the live playlist/position when it's still readable).
@discardableResult
public func restartService() async -> Bool { await runCommand(commands?.restart, [:]) }
// MARK: MediaLaunchable (delegated)
@discardableResult

View file

@ -145,6 +145,27 @@ public final class PlayerController {
/// Re-seed the default set (plum VLC + black mpv-ipc with LAN+overlay endpoints).
public func resetDevicesToDefault() { saveDevices(DevicesConfig.seeded().devices) }
// MARK: Device service restart (Devices tab)
/// Whether `id`'s host-side player service can be restarted (the target
/// supports it AND has the delegated restart command configured).
public func canRestartService(_ id: String) -> Bool {
(targets.first { $0.id == id } as? ServiceRestartable)?.canRestartService ?? false
}
/// Restart `id`'s host-side player service, then re-poll the device so its
/// row reflects the outcome. Returns whether the restart command succeeded.
@discardableResult
public func restartService(_ id: String) async -> Bool {
guard let target = targets.first(where: { $0.id == id }),
let restartable = target as? ServiceRestartable,
restartable.canRestartService else { return false }
let ok = await restartable.restartService()
Log.info("service restart on \(target.name): \(ok ? "ok" : "FAILED")")
await refreshSnapshot(for: target)
return ok
}
/// True while the Player tab is on screen. Off-tab we still poll the active
/// target slowly so the HostSelector dots stay fresh and an armed sleep
/// timer's end-of-episode check keeps running; but the fast 1.5s transport

View file

@ -60,3 +60,13 @@ public protocol PlayerTarget: AnyObject {
func previous() async
func stop() async
}
/// A target whose host-side player service can be restarted in place (black:
/// relaunch the root-owned mpv unit when it hangs or its socket goes stale,
/// resuming what was playing). The restart is delegated to a per-host command,
/// so conformance alone isn't enough `canRestartService` reflects whether
/// that command is actually configured. Drives the Devices tab action.
public protocol ServiceRestartable: AnyObject {
var canRestartService: Bool { get }
@discardableResult func restartService() async -> Bool
}

View file

@ -38,6 +38,22 @@ final class DeviceConfigDecodeTests: XCTestCase {
XCTAssertEqual(h.mpv?.volumeScale, 130)
XCTAssertEqual(h.commands?.stop, ["btv", "stop"])
XCTAssertNil(h.commands?.releases) // unspecified capability nil
// pre-`restart` config with canonical `[helper, "stop"]` teardown
// restart inferred onto the same helper (no migration step)
XCTAssertEqual(h.commands?.restart, ["btv", "restart"])
}
/// The restart inference is limited to the canonical `[helper, "stop"]`
/// shape a bespoke teardown command must NOT grow a guessed restart.
func testRestartNotInferredFromBespokeStopCommand() throws {
let json = #"""
{"devices":[
{"id":"x","name":"X","kind":"mpv-ipc","mpv":{"endpoints":["a@b"]},
"commands":{"stop":["ssh-helper","teardown","--force"]}}
]}
"""#
let cfg = try JSONDecoder().decode(DevicesConfig.self, from: Data(json.utf8))
XCTAssertNil(cfg.devices[0].commands?.restart)
}
@MainActor
@ -59,9 +75,24 @@ final class DeviceConfigDecodeTests: XCTestCase {
XCTAssertEqual(back.devices.map(\.kind), seed.devices.map(\.kind))
XCTAssertEqual(back.devices.first(where: { $0.kind == .mpvIPC })?.commands?.launchFile,
["/usr/local/bin/black-tv", "play", "{path}"])
XCTAssertEqual(back.devices.first(where: { $0.kind == .mpvIPC })?.commands?.restart,
["/usr/local/bin/black-tv", "restart"])
XCTAssertEqual(back.devices.map(\.type), [.laptop, .storage])
}
/// The Devices-tab restart action keys off the configured command: a host
/// with a `restart` template can restart; one without reports it can't.
@MainActor
func testRestartCapabilityFollowsConfiguredCommand() {
let migrated = PlayerController.makeTarget(
DeviceConfig(id: "black", name: "Black", kind: .blacktv,
ssh: SSHConn(endpoints: ["lilith@10.9.0.4"], bin: "/usr/local/bin/black-tv")))
XCTAssertEqual((migrated as? ServiceRestartable)?.canRestartService, true)
let bare = MpvTarget(id: "m", name: "M", mpv: MpvConn(endpoints: ["x@y"]), commands: nil)
XCTAssertFalse(bare.canRestartService)
}
// MARK: device type + services (Part B)
/// A pre-`type` config must infer each device's type from its player backend,

View file

@ -99,8 +99,11 @@ completion callback so finished folders get incrementally indexed.
`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). An MLX `TitleRefiner` seam exists
but is unwired (see [roadmap.md](./roadmap.md)).
`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`)
@ -157,8 +160,25 @@ bridge **server** is not part of this repo's `mcp/` tree — it lives with
- **`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. In the mesh design this same
bandwidth-arbitration brain is the intended fleet orchestrator (not yet built).
**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
@ -167,10 +187,13 @@ bridge **server** is not part of this repo's `mcp/` tree — it lives with
(TMDB/IMDb/TVmaze/AniList, routed by category) and local recommendations
(`recommend_local.py`, keyless). Invoked only during indexing/enrichment.
## 4. Planned mesh layer (designed, unbuilt)
## 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." Two graphs ride one Discord identity layer:
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.

View file

@ -1,7 +1,8 @@
# Data Model
Two sets: the **config + state schemas in use today** (the app and helpers read
and write these), and the **planned fleet/mesh data model** (designed, unbuilt).
and write these), and the **fleet/mesh data model** (single-fleet core
implemented in the governor's fleet engine; cross-fleet parts still design).
---
@ -52,7 +53,8 @@ entries without a `type` get one inferred from the player backend.
"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"]
"stop": ["/usr/local/bin/black-tv","stop"],
"restart": ["/usr/local/bin/black-tv","restart"]
}
}
]
@ -120,11 +122,52 @@ ratings, genres, enrichedAt }`.
---
## Planned fleet/mesh data model
### `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):
```jsonc
{
"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`](../.project/history/20260608_fleet-manager-mesh-design.md).
**None of this is implemented.**
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

View file

@ -115,7 +115,10 @@ localhost+overlay networking).
Devices are editable in-app (Devices tab: add/edit/delete, make-active, set
type/services, reload, reset, reveal `devices.json`). The list shows a
per-device system-load badge (low/med/high).
per-device system-load badge (low/med/high). Devices with a configured `restart`
command template (black by default) get a **Restart service** menu action that
hard-restarts the host-side player (`black-tv restart`: relaunch the mpv unit,
resuming the live playlist/position; clean teardown when idle/hung).
## governor (`portable-net-tv`)
@ -132,6 +135,24 @@ Runs as a launchd background agent (Apple Events to VLC are blocked there, which
is why it reads VLC over HTTP, not AppleScript). Config: see
[data-model.md](./data-model.md#configjson--governor-portable-net-tv).
### Fleet engine (`portable-net-tv fleet …`)
```sh
portable-net-tv fleet status # registry + duty assignment + warnings
portable-net-tv fleet duties # assign duties; Δ-log changes since last run
portable-net-tv fleet custody # floor-check every title; print re-pin plans
portable-net-tv fleet reaper # classify torrents healthy|stalled|dead
portable-net-tv fleet reaper --apply # + safe nudges only (reannounce/verify)
portable-net-tv fleet peers <q> # peers_for(infohash|title), provenance-tagged
```
All subcommands take `--json`. Reads the fleet registry from
`~/.config/tv-anarchy/fleet.json` (array form; `devices.json` fallback) — see
[data-model.md](./data-model.md#fleetjson--the-fleet-registry-app-side--governor-policy).
Read-only by default: re-pins, mesh recoveries, and re-searches are printed as
plans; only `reaper --apply` mutates anything (idempotent transmission ops).
Tests: `bun test` in `governor/`.
## mcp (`plum-control-mcp`)
TypeScript/Bun. Serves both an MCP stdio server (for Claude) and the CLI bridge

View file

@ -1,5 +1,28 @@
# fleet/ — fleet manager + self-healing torrent mesh
Design spec: ../.project/history/20260608_fleet-manager-mesh-design.md
Not yet implemented. Build order starts at stage 1 (host registry + duty
assignment, single fleet) per the spec.
**Status:** the single-fleet core (stage 1 + stage 3) is **implemented in the
governor** — `../governor/src/fleet/` — not here:
- registry ingest (`~/.config/tv-anarchy/fleet.json` array form authoritative,
`devices.json` fallback)
- deterministic duty assignment (broadcast / f2f_relay / public_swarm_face)
with the spec invariants
- custody floor-check (`custodians_of`, rolling baton, re-pin planning)
- zombie reaper (healthy | stalled | dead; mesh-first recovery planning)
- peer-source model with both private-tracker policy gates + `peers_for`
CLI: `portable-net-tv fleet status|duties|custody|reaper [--apply]|peers <q>`
(see ../docs/operations.md). Tests: `bun test` in `../governor`.
This directory remains the placeholder for the parts that are still design-only
and/or blocked on infrastructure outside this repo:
- re-pin **actuation** (cross-host torrent copies) and automated re-search
- the fleet WireGuard fabric (plane 1 — blocked on the `10.9.0.4` question)
- a broadcast host serving `peers_for` to other devices
- friend-mesh / F2F relay (stage 4), private-tracker sources (stage 5),
Discord planes, multi-identity
Build order and the up-to-date status table live in ../docs/roadmap.md.

View file

@ -5,8 +5,9 @@
# Deployed to /usr/local/bin/black-tv on black; invoked over SSH by the
# plum-control MCP `blacktv` module (mirrors transmission-remote-over-ssh).
#
# One long-lived mpv instance plays to the TV; every verb except `play`/`stop`
# goes through its JSON IPC socket, so volume/seek/pause never restart playback.
# One long-lived mpv instance plays to the TV; every verb except `play`/`stop`/
# `restart` goes through its JSON IPC socket, so volume/seek/pause never restart
# playback.
# black has no graphical session — mpv renders straight to KMS (--vo=drm) and
# the GPU driver is brought up on demand (see ensure_display).
# No `pipefail`: several pipes end in `grep -q`/`head -1`, which exit early and
@ -79,7 +80,7 @@ kill_existing() {
sudo systemctl stop "$UNIT" psych-mpv 2>/dev/null || true # psych-mpv = legacy ad-hoc unit
sudo systemctl reset-failed "$UNIT" psych-mpv 2>/dev/null || true
sudo pkill -x mpv 2>/dev/null || true
rm -f "$SOCK" 2>/dev/null || true
sudo rm -f "$SOCK" 2>/dev/null || true # root-owned socket in sticky /tmp — plain rm can't
sleep 1
}
launch() { # launch <playlist-file> [resume_seconds]
@ -99,6 +100,40 @@ launch() { # launch <playlist-file> [resume_seconds]
--no-resume-playback "${hook[@]}" \
--fs --really-quiet --playlist="$1"
}
# Live state for `restart`: first line = position seconds, then the LIVE playlist
# from the current entry onward — read over IPC, not from $PLAYLIST, because an
# IPC-built queue (the app's enqueue) never touches that file. Empty output when
# idle or unreadable. timeout-guarded: a hung mpv is the main reason to restart,
# so the capture itself must never hang.
capture_state() {
[ -S "$SOCK" ] || return 0
printf '%s\n%s\n' \
'{"command":["get_property","playlist"],"request_id":1}' \
'{"command":["get_property","time-pos"],"request_id":2}' \
| sudo timeout 5 socat - "$SOCK" 2>/dev/null \
| python3 -c '
import json, sys
pl, secs = None, None
for line in sys.stdin:
try:
o = json.loads(line)
except ValueError:
continue
if o.get("error") != "success":
continue
if o.get("request_id") == 1: pl = o.get("data")
if o.get("request_id") == 2: secs = o.get("data")
if not pl:
sys.exit(0)
cur = next((i for i, e in enumerate(pl) if e.get("current")), None)
if cur is None:
sys.exit(0)
print(int(secs or 0))
for e in pl[cur:]:
if e.get("filename"):
print(e["filename"])
' 2>/dev/null || true
}
# --- playlist building ------------------------------------------------------
build_dir_playlist() { # <dir> -> writes $PLAYLIST, echoes count
@ -361,9 +396,24 @@ case "$cmd" in
seek) [ $# -ge 1 ] || die "usage: black-tv seek <seconds>"; ipc "{\"command\":[\"seek\",$1]}" >/dev/null; echo "seek ${1}s" ;;
next) ipc '{"command":["playlist-next"]}' >/dev/null; echo next ;;
prev) ipc '{"command":["playlist-prev"]}' >/dev/null; echo prev ;;
stop) sudo systemctl stop "$UNIT" 2>/dev/null || true; sudo pkill -x mpv 2>/dev/null || true; rm -f "$SOCK"; echo stopped ;;
stop) sudo systemctl stop "$UNIT" 2>/dev/null || true; sudo pkill -x mpv 2>/dev/null || true; sudo rm -f "$SOCK" 2>/dev/null || true; echo stopped ;;
restart)
# Hard-restart the player service: tear down the unit and, if something was
# playing, relaunch the remaining playlist resuming at the captured position.
# Idle / hung-unreadable mpv → clean teardown only (a fresh slate to play into).
state=$(capture_state)
if [ -n "$state" ]; then
secs=$(head -1 <<<"$state")
tail -n +2 <<<"$state" > "$PLAYLIST"
launch "$PLAYLIST" "$secs"
echo "restarted: resumed at ${secs}s"
else
kill_existing
echo "restarted: nothing playing — unit/socket cleaned up"
fi
;;
status) status_json ;;
stats) stats_json ;;
ensure-display) ensure_display; echo "display ready: $(cat /sys/class/drm/card0-${CONNECTOR}/status 2>/dev/null)" ;;
*) die "usage: black-tv {play <path>|play-show <q> [S] [E]|resume-show <q>|enqueue <x>|goto-ep N|releases|resolve-release <rel>|switch <rel>|pause|resume|toggle|vol N|seek S|next|prev|stop|status|stats|watched [q]|ensure-display}" ;;
*) die "usage: black-tv {play <path>|play-show <q> [S] [E]|resume-show <q>|enqueue <x>|goto-ep N|releases|resolve-release <rel>|switch <rel>|pause|resume|toggle|vol N|seek S|next|prev|stop|restart|status|stats|watched [q]|ensure-display}" ;;
esac