tv-anarchy/Sources/TVAnarchyCore/DeviceConfig.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)
}
}