358 lines
17 KiB
Swift
358 lines
17 KiB
Swift
import Foundation
|
|
|
|
/// What kind of player backend a device speaks. Orthogonal to `DeviceType`: a
|
|
/// device may stream (has a backend) or not (a pure storage/seed node still
|
|
/// carries a kind for config simplicity, but `services.stream` gates playback).
|
|
public enum HostKind: String, Codable, Sendable, CaseIterable, Identifiable {
|
|
case vlc // VLC HTTP/Lua interface
|
|
case blacktv // the legacy `black-tv` verb script over SSH (being retired)
|
|
case mpvIPC = "mpv-ipc" // generic mpv JSON IPC over SSH + delegated commands
|
|
case quicktime // local QuickTime Player driven by AppleScript (zero-install)
|
|
|
|
public var id: String { rawValue }
|
|
/// Kinds offered in the editor (blacktv is legacy/auto-migrated, hidden).
|
|
public static var editable: [HostKind] { [.mpvIPC, .vlc, .quicktime] }
|
|
public var label: String {
|
|
switch self {
|
|
case .vlc: "VLC (HTTP)"
|
|
case .blacktv: "black-tv (legacy)"
|
|
case .mpvIPC: "mpv over SSH"
|
|
case .quicktime: "QuickTime (local)"
|
|
}
|
|
}
|
|
/// A locally-driven player (no network host)?
|
|
public var isLocal: Bool { self == .vlc || self == .quicktime }
|
|
}
|
|
|
|
/// The user-facing role of a device — a quick, overridable preset of which
|
|
/// features/services it runs. Maps onto the planned fleet host classes
|
|
/// (see `.project/history/20260608_device-types-and-duties.md`).
|
|
public enum DeviceType: String, Codable, Sendable, CaseIterable, Identifiable {
|
|
case cellphone // fleet "consumer": stream + offline self-cache
|
|
case laptop // fleet "roamer": stream + offline + TTL-seed while playing
|
|
case storage // fleet "server": holds copies (custody); usually also streams
|
|
case seed // fleet "seedbox": public-swarm face + custody
|
|
case broadcast // fleet "broadcast": the always-on mesh anchor — registry,
|
|
// F2F rendezvous, Discord bridge; exactly one per fleet
|
|
|
|
public var id: String { rawValue }
|
|
public var label: String {
|
|
switch self {
|
|
case .cellphone: "Cellphone"
|
|
case .laptop: "Laptop"
|
|
case .storage: "Storage"
|
|
case .seed: "Seedbox"
|
|
case .broadcast: "Broadcast Station"
|
|
}
|
|
}
|
|
public var icon: String {
|
|
switch self {
|
|
case .cellphone: "iphone"
|
|
case .laptop: "laptopcomputer"
|
|
case .storage: "externaldrive.fill"
|
|
case .seed: "server.rack"
|
|
case .broadcast: "antenna.radiowaves.left.and.right"
|
|
}
|
|
}
|
|
/// The fleet host class this maps to (for the planned mesh layer).
|
|
public var fleetClass: String {
|
|
switch self {
|
|
case .cellphone: "consumer"
|
|
case .laptop: "roamer"
|
|
case .storage: "server"
|
|
case .seed: "seedbox"
|
|
case .broadcast: "broadcast"
|
|
}
|
|
}
|
|
/// The overridable preset of services this type runs. The user can flip any
|
|
/// of these per-device in the editor; this is only the default.
|
|
public var defaultServices: DeviceServices {
|
|
switch self {
|
|
case .cellphone: DeviceServices(stream: true, offlineCache: true)
|
|
case .laptop: DeviceServices(stream: true, offlineCache: true, ttlSeed: true)
|
|
case .storage: DeviceServices(stream: true, custody: true)
|
|
case .seed: DeviceServices(stream: false, custody: true, publicSwarmFace: true)
|
|
case .broadcast: DeviceServices(stream: false, publicSwarmFace: true,
|
|
f2fRelay: true, meshAnchor: true)
|
|
}
|
|
}
|
|
/// Inferred type for a legacy host config that predates `type` — keyed off the
|
|
/// player backend so `black` (mpv-over-ssh) becomes a streaming storage node
|
|
/// and a local vlc/quicktime player becomes a laptop.
|
|
public static func inferred(fromKind kind: HostKind) -> DeviceType {
|
|
kind.isLocal ? .laptop : .storage
|
|
}
|
|
}
|
|
|
|
/// The overridable capability/service flags for a device. `DeviceType` seeds
|
|
/// these; the user flips any in the editor. `custody`/`ttlSeed`/`publicSwarmFace`
|
|
/// are **planned** (designed, mesh actuation not yet built) — the UI shows them as
|
|
/// such; `stream`/`offlineCache` are actuated today. Custody and stream are
|
|
/// independent (a storage node like `black` both holds copies and streams).
|
|
public struct DeviceServices: Codable, Sendable, Equatable {
|
|
/// Eligible as a playback target.
|
|
public var stream: Bool
|
|
/// Pulls the next-Y-episodes-of-the-most-recent-Z-shows to local disk.
|
|
public var offlineCache: Bool
|
|
/// Seeds with a TTL while actively playing (planned actuation).
|
|
public var ttlSeed: Bool
|
|
/// Holds the N-copy replication floor for wanted titles (planned; = fleet
|
|
/// `custody_floor` duty).
|
|
public var custody: Bool
|
|
/// The node that contacts DHT/public trackers, keeping home IPs dark (planned;
|
|
/// = fleet `public_swarm_face` duty).
|
|
public var publicSwarmFace: Bool
|
|
/// Relays friend-to-friend requests and bytes across the mesh (planned; = fleet
|
|
/// `f2f_relay` duty).
|
|
public var f2fRelay: Bool
|
|
/// The fleet anchor: holds the aggregated peer registry, anchors F2F
|
|
/// rendezvous, runs the Discord bridge (planned; = fleet `broadcast` duty —
|
|
/// exactly one per fleet).
|
|
public var meshAnchor: Bool
|
|
|
|
public init(stream: Bool = false, offlineCache: Bool = false, ttlSeed: Bool = false,
|
|
custody: Bool = false, publicSwarmFace: Bool = false,
|
|
f2fRelay: Bool = false, meshAnchor: Bool = false) {
|
|
self.stream = stream; self.offlineCache = offlineCache; self.ttlSeed = ttlSeed
|
|
self.custody = custody; self.publicSwarmFace = publicSwarmFace
|
|
self.f2fRelay = f2fRelay; self.meshAnchor = meshAnchor
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case stream, offlineCache, ttlSeed, custody, publicSwarmFace, f2fRelay, meshAnchor
|
|
}
|
|
public init(from d: Decoder) throws {
|
|
let c = try d.container(keyedBy: CodingKeys.self)
|
|
stream = try c.decodeIfPresent(Bool.self, forKey: .stream) ?? false
|
|
offlineCache = try c.decodeIfPresent(Bool.self, forKey: .offlineCache) ?? false
|
|
ttlSeed = try c.decodeIfPresent(Bool.self, forKey: .ttlSeed) ?? false
|
|
custody = try c.decodeIfPresent(Bool.self, forKey: .custody) ?? false
|
|
publicSwarmFace = try c.decodeIfPresent(Bool.self, forKey: .publicSwarmFace) ?? false
|
|
f2fRelay = try c.decodeIfPresent(Bool.self, forKey: .f2fRelay) ?? false
|
|
meshAnchor = try c.decodeIfPresent(Bool.self, forKey: .meshAnchor) ?? false
|
|
}
|
|
}
|
|
|
|
/// Connection to a generic mpv host: its JSON IPC socket reached over SSH, plus
|
|
/// how to read it (root-owned sockets need `sudo socat`). `volumeScale` is the
|
|
/// slider max (mpv's volume is already a percentage; default mirrors mpv's
|
|
/// `--volume-max` of 130).
|
|
public struct MpvConn: Codable, Sendable, Equatable {
|
|
public var endpoints: [String]
|
|
public var socket: String
|
|
public var sudo: Bool
|
|
public var socat: String
|
|
public var volumeScale: Int
|
|
|
|
public init(endpoints: [String], socket: String = "/tmp/mpv.sock",
|
|
sudo: Bool = true, socat: String = "socat", volumeScale: Int = 130) {
|
|
self.endpoints = endpoints; self.socket = socket
|
|
self.sudo = sudo; self.socat = socat; self.volumeScale = volumeScale
|
|
}
|
|
|
|
// Decode with defaults so a minimal `{ "endpoints": [...] }` is valid.
|
|
enum CodingKeys: String, CodingKey { case endpoints, socket, sudo, socat, volumeScale }
|
|
public init(from d: Decoder) throws {
|
|
let c = try d.container(keyedBy: CodingKeys.self)
|
|
endpoints = try c.decode([String].self, forKey: .endpoints)
|
|
socket = try c.decodeIfPresent(String.self, forKey: .socket) ?? "/tmp/mpv.sock"
|
|
sudo = try c.decodeIfPresent(Bool.self, forKey: .sudo) ?? true
|
|
socat = try c.decodeIfPresent(String.self, forKey: .socat) ?? "socat"
|
|
volumeScale = try c.decodeIfPresent(Int.self, forKey: .volumeScale) ?? 130
|
|
}
|
|
}
|
|
|
|
/// Per-host command templates (argv arrays) for the operations a generic mpv
|
|
/// host can't do over IPC: launch/library/stats/teardown. A nil template means
|
|
/// the host lacks that capability. Tokens: `{query}`, `{season?}`, `{episode?}`,
|
|
/// `{path}`, `{releaseId}` (see CommandTemplate).
|
|
public struct CommandsConfig: Codable, Sendable, Equatable {
|
|
/// Play a file by its (black-side) path. This is the ONLY launch verb — playback
|
|
/// always addresses the exact file the library resolved (no host-side name
|
|
/// lookup; see `LaunchRequest`). An old config's `launchShow`/`launchResume` keys
|
|
/// are simply ignored on decode.
|
|
public var launchFile: [String]?
|
|
public var releases: [String]?
|
|
public var resolveRelease: [String]?
|
|
public var stats: [String]?
|
|
public var stop: [String]?
|
|
|
|
public init(launchFile: [String]? = nil, releases: [String]? = nil,
|
|
resolveRelease: [String]? = nil, stats: [String]? = nil, stop: [String]? = nil) {
|
|
self.launchFile = launchFile; self.releases = releases
|
|
self.resolveRelease = resolveRelease; self.stats = stats; self.stop = stop
|
|
}
|
|
|
|
/// 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 {
|
|
CommandsConfig(
|
|
launchFile: [bin, "play", "{path}"],
|
|
releases: [bin, "releases"],
|
|
resolveRelease: [bin, "resolve-release", "{releaseId}"],
|
|
stats: [bin, "stats"],
|
|
stop: [bin, "stop"])
|
|
}
|
|
}
|
|
|
|
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 device: its player backend (`kind`), its role (`type`) and the
|
|
/// overridable `services` that role presets. Password for `vlc` is NOT stored here
|
|
/// — it's resolved from the portable-net-tv config at runtime (see VLCConfig).
|
|
public struct DeviceConfig: Codable, Sendable, Identifiable, Equatable {
|
|
public var id: String
|
|
public var name: String
|
|
public var kind: HostKind
|
|
public var type: DeviceType
|
|
public var services: DeviceServices
|
|
public var vlc: VLCConn?
|
|
public var ssh: SSHConn? // legacy blacktv
|
|
public var mpv: MpvConn? // mpv-ipc
|
|
public var commands: CommandsConfig?
|
|
|
|
public init(id: String, name: String, kind: HostKind, type: DeviceType? = nil,
|
|
services: DeviceServices? = nil, vlc: VLCConn? = nil,
|
|
ssh: SSHConn? = nil, mpv: MpvConn? = nil, commands: CommandsConfig? = nil) {
|
|
self.id = id; self.name = name; self.kind = kind
|
|
let t = type ?? DeviceType.inferred(fromKind: kind)
|
|
self.type = t
|
|
self.services = services ?? t.defaultServices
|
|
self.vlc = vlc; self.ssh = ssh; self.mpv = mpv; self.commands = commands
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id, name, kind, type, services, vlc, ssh, mpv, commands
|
|
}
|
|
/// Tolerant decode: a pre-`type` host config infers its `type` from `kind` and
|
|
/// takes that type's default `services` — so an existing `black`/`plum-vlc`
|
|
/// becomes a storage/laptop device with sensible services, no migration step.
|
|
public init(from d: Decoder) throws {
|
|
let c = try d.container(keyedBy: CodingKeys.self)
|
|
id = try c.decode(String.self, forKey: .id)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
kind = try c.decode(HostKind.self, forKey: .kind)
|
|
let t = try c.decodeIfPresent(DeviceType.self, forKey: .type) ?? DeviceType.inferred(fromKind: kind)
|
|
type = t
|
|
services = try c.decodeIfPresent(DeviceServices.self, forKey: .services) ?? t.defaultServices
|
|
vlc = try c.decodeIfPresent(VLCConn.self, forKey: .vlc)
|
|
ssh = try c.decodeIfPresent(SSHConn.self, forKey: .ssh)
|
|
mpv = try c.decodeIfPresent(MpvConn.self, forKey: .mpv)
|
|
commands = try c.decodeIfPresent(CommandsConfig.self, forKey: .commands)
|
|
}
|
|
}
|
|
|
|
public struct DevicesConfig: Codable, Sendable {
|
|
public var devices: [DeviceConfig]
|
|
public init(devices: [DeviceConfig]) { self.devices = devices }
|
|
|
|
enum CodingKeys: String, CodingKey { case devices, hosts }
|
|
/// Decode `devices`, falling back to the pre-rename `hosts` key so an existing
|
|
/// config loads unchanged.
|
|
public init(from d: Decoder) throws {
|
|
let c = try d.container(keyedBy: CodingKeys.self)
|
|
if let ds = try c.decodeIfPresent([DeviceConfig].self, forKey: .devices) {
|
|
devices = ds
|
|
} else {
|
|
devices = try c.decodeIfPresent([DeviceConfig].self, forKey: .hosts) ?? []
|
|
}
|
|
}
|
|
public func encode(to e: Encoder) throws {
|
|
var c = e.container(keyedBy: CodingKeys.self)
|
|
try c.encode(devices, forKey: .devices)
|
|
}
|
|
|
|
public static func configURL() -> URL {
|
|
FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".config/tv-anarchy/devices.json")
|
|
}
|
|
|
|
/// Pre-rename locations, read once to migrate an existing config forward:
|
|
/// the `hosts.json` of this app, then the even-older `plumtv/hosts.json`.
|
|
static func legacyURLs() -> [URL] {
|
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
return [
|
|
home.appendingPathComponent(".config/tv-anarchy/hosts.json"),
|
|
home.appendingPathComponent(".config/plumtv/hosts.json"),
|
|
]
|
|
}
|
|
|
|
/// Default seed — the two real devices on this network. `black` is a storage
|
|
/// node that also streams (mpv-IPC control, delegating launch/library/stats to
|
|
/// its `black-tv` helper); `plum` is the local laptop player (VLC).
|
|
public static func seeded() -> DevicesConfig {
|
|
DevicesConfig(devices: [
|
|
DeviceConfig(id: "plum-vlc", name: "Plum VLC", kind: .vlc, type: .laptop,
|
|
vlc: VLCConn(host: "127.0.0.1", port: 8080)),
|
|
DeviceConfig(id: "black", name: "Black TV", kind: .mpvIPC, type: .storage,
|
|
mpv: MpvConn(endpoints: ["lilith@10.0.0.11", "lilith@10.9.0.4"]),
|
|
commands: CommandsConfig.blackTVDefaults(bin: "/usr/local/bin/black-tv")),
|
|
])
|
|
}
|
|
|
|
/// Load `~/.config/tv-anarchy/devices.json`; migrate a pre-rename `hosts.json`
|
|
/// (this app's, then plumtv's) forward if present; else seed.
|
|
public static func loadOrSeed() -> DevicesConfig {
|
|
if let cfg = decode(configURL()), !cfg.devices.isEmpty { return cfg }
|
|
for legacy in legacyURLs() {
|
|
if let old = decode(legacy), !old.devices.isEmpty {
|
|
try? old.save() // migrate to devices.json
|
|
return old
|
|
}
|
|
}
|
|
let seed = seeded()
|
|
try? seed.save()
|
|
return seed
|
|
}
|
|
|
|
private static func decode(_ url: URL) -> DevicesConfig? {
|
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
return try? JSONDecoder().decode(DevicesConfig.self, from: data)
|
|
}
|
|
|
|
/// The local player kind currently configured (vlc/quicktime), if any.
|
|
public var localPlayerKind: HostKind? {
|
|
devices.first { $0.kind == .vlc || $0.kind == .quicktime }?.kind
|
|
}
|
|
|
|
/// Swap the local player device to `kind`, preserving position. Only local kinds
|
|
/// (vlc/quicktime) are meaningful here; anything else is ignored.
|
|
public mutating func setLocalPlayer(_ kind: HostKind) {
|
|
let device: DeviceConfig
|
|
switch kind {
|
|
case .quicktime:
|
|
device = DeviceConfig(id: "local-quicktime", name: "QuickTime", kind: .quicktime, type: .laptop)
|
|
case .vlc:
|
|
device = DeviceConfig(id: "plum-vlc", name: "Plum VLC", kind: .vlc, type: .laptop,
|
|
vlc: VLCConn(host: "127.0.0.1", port: 8080))
|
|
default:
|
|
return
|
|
}
|
|
if let i = devices.firstIndex(where: { $0.kind == .vlc || $0.kind == .quicktime }) {
|
|
devices[i] = device
|
|
} else {
|
|
devices.insert(device, at: 0)
|
|
}
|
|
}
|
|
|
|
public func save() throws {
|
|
let url = DevicesConfig.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)
|
|
}
|
|
}
|