tv-anarchy/Sources/PlumTVCore/HostConfig.swift
Natalie e2977041aa feat: PlumTV — native macOS player with config-driven hosts
Phase 0/1: SwiftUI app (XcodeGen), PlumTVCore framework, player MVP.
- PlayerTarget protocol + VLCTarget (HTTP) and BlackTVTarget (ssh black-tv)
- config-driven hosts (~/.config/plumtv/hosts.json), seeded plum-vlc + black
- reliability: SSH ControlMaster (5s->~1s/poll), endpoint pinning (LAN->overlay),
  single-flight polling, keep-last-known on transient failure, debounced volume
- Hosts pane shows live connection state; Player has target picker + transport
- unit tests for status decoding

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 20:24:55 -07:00

78 lines
3 KiB
Swift

import Foundation
/// What kind of backend a host speaks.
public enum HostKind: String, Codable, Sendable {
case vlc // VLC HTTP/Lua interface
case blacktv // the `black-tv` script over SSH
}
public struct VLCConn: Codable, Sendable, Equatable {
public var host: String
public var port: Int
public init(host: String, port: Int) { self.host = host; self.port = port }
}
public struct SSHConn: Codable, Sendable, Equatable {
/// Ordered endpoints to try (e.g. LAN first, overlay fallback). The working
/// one is pinned at runtime; we only re-probe the others on failure.
public var endpoints: [String]
public var bin: String
public init(endpoints: [String], bin: String) { self.endpoints = endpoints; self.bin = bin }
}
/// One configurable playback host. Password for `vlc` is NOT stored here it's
/// resolved from the portable-net-tv config at runtime (see VLCConfig).
public struct HostConfig: Codable, Sendable, Identifiable, Equatable {
public var id: String
public var name: String
public var kind: HostKind
public var vlc: VLCConn?
public var ssh: SSHConn?
public init(id: String, name: String, kind: HostKind, vlc: VLCConn? = nil, ssh: SSHConn? = nil) {
self.id = id; self.name = name; self.kind = kind; self.vlc = vlc; self.ssh = ssh
}
}
public struct HostsConfig: Codable, Sendable {
public var hosts: [HostConfig]
public init(hosts: [HostConfig]) { self.hosts = hosts }
public static func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/plumtv/hosts.json")
}
/// Default seed the two real player targets on this network. black tries
/// its LAN address first, then the WG overlay (LAN flaps).
public static func seeded() -> HostsConfig {
HostsConfig(hosts: [
HostConfig(id: "plum-vlc", name: "Plum VLC", kind: .vlc,
vlc: VLCConn(host: "127.0.0.1", port: 8080)),
HostConfig(id: "black", name: "Black TV", kind: .blacktv,
ssh: SSHConn(endpoints: ["lilith@10.0.0.11", "lilith@10.9.0.4"],
bin: "/usr/local/bin/black-tv")),
])
}
/// Load `~/.config/plumtv/hosts.json`; if missing/empty, write and return the seed.
public static func loadOrSeed() -> HostsConfig {
if let data = try? Data(contentsOf: configURL()),
let cfg = try? JSONDecoder().decode(HostsConfig.self, from: data),
!cfg.hosts.isEmpty {
return cfg
}
let seed = seeded()
try? seed.save()
return seed
}
public func save() throws {
let url = HostsConfig.configURL()
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
let enc = JSONEncoder()
enc.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
try enc.encode(self).write(to: url, options: .atomic)
}
}