feat(apps): ✨ add fleet engine mesh core integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
7ff780fe56
commit
a86e68c525
13 changed files with 399 additions and 19 deletions
95
.project/handoffs/20260609_fleet-engine-title-refiner.md
Normal file
95
.project/handoffs/20260609_fleet-engine-title-refiner.md
Normal 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.
|
||||
11
.project/handoffs/README.md
Normal file
11
.project/handoffs/README.md
Normal 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.**
|
||||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue