import Foundation #if canImport(AppKit) import AppKit #endif /// 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) case roku // Roku ECP (REST on the LAN) — transport control of the stick's // own playback; never a library playback destination (no // MediaLaunchable: a Roku can't open our NFS paths) case registry = "none" // no player backend at all — a registry-only entry (phones/tablets stream via their own app) // (phones/tablets: they stream via their own app, the // mac app never plays TO them) public var id: String { rawValue } /// Kinds offered in the editor (blacktv is legacy/auto-migrated, hidden). public static var editable: [HostKind] { [.mpvIPC, .vlc, .quicktime, .roku, .registry] } public var label: String { switch self { case .vlc: "VLC (HTTP)" case .blacktv: "black-tv (legacy)" case .mpvIPC: "mpv over SSH" case .quicktime: "QuickTime (local)" case .roku: "Roku (ECP)" case .registry: "None (registry only)" } } /// 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 governor host classes (internal /// engine term "fleet class" in governor/src/fleet/; see devices pillar (product language: "install" + Devices tab for registry/pairing; internal fleet kept for now per v2/plan §1 and pillars/devices.md). See v2/pillars/devices.md for the full model. public enum DeviceType: String, Codable, Sendable, CaseIterable, Identifiable { case cellphone // governor "consumer": stream + offline self-cache (legacy fleet term in engine) case laptop // governor "roamer": stream + offline + TTL-seed while playing case storage // governor "server": holds copies (custody); usually also streams case seed // governor "seedbox": public-swarm face + custody case broadcast // governor "broadcast": the always-on mesh anchor — registry, // F2F rendezvous, Discord bridge; exactly one per install 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 governor host class this maps to (for the mesh layer / duties engine). 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 { if kind == .roku { return .cellphone } // governor "consumer": a stream-only endpoint (legacy fleet term) return 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; = governor /// `custody_floor` duty in the fleet engine). public var custody: Bool /// The node that contacts DHT/public trackers, keeping home IPs dark (planned; /// = governor `public_swarm_face` duty in the fleet engine). public var publicSwarmFace: Bool /// Relays friend-to-friend requests and bytes across the mesh (planned; = governor /// `f2f_relay` duty in the fleet engine). public var f2fRelay: Bool /// The install anchor (meshAnchor): holds the aggregated peer registry, anchors F2F /// rendezvous, runs the Discord bridge (planned; = governor `broadcast` duty — /// exactly one per install). 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 `{ }` or `{ "endpoints": [...] }` is valid. // An empty `endpoints` array means "derive from the device's hostname". 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.decodeIfPresent([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]? /// Restart the host-side player service in place (black: relaunch the mpv /// unit, resuming the live playlist/position). Drives the Devices tab action. public var restart: [String]? public init(launchFile: [String]? = nil, releases: [String]? = nil, resolveRelease: [String]? = nil, stats: [String]? = nil, stop: [String]? = nil, restart: [String]? = nil) { self.launchFile = launchFile; self.releases = releases self.resolveRelease = resolveRelease; self.stats = stats; self.stop = stop self.restart = restart } /// The helper bin the delegated commands run (e.g. `/usr/local/bin/black-tv`) /// — the first word of whichever template is configured. Keys the deployment /// freshness check and the in-app updater. public var helperBin: String? { (stats ?? stop ?? launchFile ?? releases ?? resolveRelease ?? restart)?.first } enum CodingKeys: String, CodingKey { case launchFile, releases, resolveRelease, stats, stop, restart } /// Tolerant decode: a pre-`restart` config whose teardown is the canonical /// `[, "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 { CommandsConfig( launchFile: [bin, "play", "{path}"], releases: [bin, "releases"], resolveRelease: [bin, "resolve-release", "{releaseId}"], stats: [bin, "stats"], stop: [bin, "stop"], restart: [bin, "restart"]) } } 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 } } /// Per-device streaming buffer policy — how many seconds of playback to hold /// ahead when fetching from the storage server on demand. Capped at half the /// current episode length at runtime (see `effectiveBufferSeconds`). public struct StreamPolicy: Codable, Sendable, Equatable { /// Target buffer in seconds of playback (clamped to half episode duration). public var bufferSeconds: Int public static let defaults = StreamPolicy(bufferSeconds: 120) /// Typical scripted episode length for UI hints when nothing is playing. public static let typicalEpisodeSeconds = 22 * 60 public init(bufferSeconds: Int = 120) { self.bufferSeconds = bufferSeconds } enum CodingKeys: String, CodingKey { case bufferSeconds } public init(from d: Decoder) throws { let c = try d.container(keyedBy: CodingKeys.self) bufferSeconds = try c.decodeIfPresent(Int.self, forKey: .bufferSeconds) ?? 120 } /// User-requested buffer capped at half the episode (minimum 15s). public func effectiveBufferSeconds(episodeDuration: Double?) -> Int { let requested = min(max(bufferSeconds, 15), 3600) guard let dur = episodeDuration, dur > 0 else { return requested } let cap = max(15, Int(dur / 2)) return min(requested, cap) } /// Upper bound for the settings slider — half a typical episode when idle. public func sliderMax(episodeDuration: Double?) -> Int { guard let dur = episodeDuration, dur > 0 else { return max(15, Int(Double(Self.typicalEpisodeSeconds) / 2)) } return max(15, Int(dur / 2)) } } /// Per-device offline cache policy — warmup window and culling budget. Lives on /// each device in `devices.json`; actuated today for the local player (laptop). public struct OfflineCachePolicy: Codable, Sendable, Equatable { public var warmupEnabled: Bool /// Episodes from the resume point forward (inclusive). public var episodesAhead: Int /// Episodes before the resume point. public var episodesBehind: Int public var shows: Int public var fromContinueWatching: Bool public var cullEnabled: Bool /// Share of the drive's total storage (where the cache lives) used as the cap. public var budgetPercent: Int /// Always keep at least this many GiB free on the cache volume (downloads + cull). public var reserveFreeGB: Int /// Optional override for the on-disk cache root (nil → default under ~/Movies). public var cacheDir: String? /// Basenames of files to always protect from culling (e.g. favorite clips from adult feature) /// and to highly prioritize for restore/refetch when missing. public var pinned: [String] public static let defaults = OfflineCachePolicy( warmupEnabled: true, episodesAhead: 3, episodesBehind: 0, shows: 5, fromContinueWatching: true, cullEnabled: true, budgetPercent: 15, reserveFreeGB: 5, cacheDir: nil, pinned: []) public init(warmupEnabled: Bool = true, episodesAhead: Int = 3, episodesBehind: Int = 0, shows: Int = 5, fromContinueWatching: Bool = true, cullEnabled: Bool = true, budgetPercent: Int = 15, reserveFreeGB: Int = 5, cacheDir: String? = nil, pinned: [String] = []) { self.warmupEnabled = warmupEnabled; self.episodesAhead = episodesAhead self.episodesBehind = episodesBehind; self.shows = shows self.fromContinueWatching = fromContinueWatching; self.cullEnabled = cullEnabled self.budgetPercent = budgetPercent; self.reserveFreeGB = reserveFreeGB self.cacheDir = cacheDir self.pinned = pinned } enum CodingKeys: String, CodingKey { case warmupEnabled, episodesAhead, episodesBehind, shows, fromContinueWatching case cullEnabled, budgetPercent, reserveFreeGB, cacheDir, pinned } public init(from d: Decoder) throws { let c = try d.container(keyedBy: CodingKeys.self) warmupEnabled = try c.decodeIfPresent(Bool.self, forKey: .warmupEnabled) ?? true episodesAhead = try c.decodeIfPresent(Int.self, forKey: .episodesAhead) ?? 3 episodesBehind = try c.decodeIfPresent(Int.self, forKey: .episodesBehind) ?? 0 shows = try c.decodeIfPresent(Int.self, forKey: .shows) ?? 5 fromContinueWatching = try c.decodeIfPresent(Bool.self, forKey: .fromContinueWatching) ?? true cullEnabled = try c.decodeIfPresent(Bool.self, forKey: .cullEnabled) ?? true budgetPercent = try c.decodeIfPresent(Int.self, forKey: .budgetPercent) ?? 15 reserveFreeGB = try c.decodeIfPresent(Int.self, forKey: .reserveFreeGB) ?? 5 cacheDir = try c.decodeIfPresent(String.self, forKey: .cacheDir) pinned = try c.decodeIfPresent([String].self, forKey: .pinned) ?? [] } } /// Connection to a Roku's External Control Protocol — plain unauthenticated REST /// on the LAN, port 8060 by default. Discoverable via SSDP (`ST: roku:ecp`). public struct RokuConn: Codable, Sendable, Equatable { public var host: String public var port: Int public init(host: String, port: Int = 8060) { self.host = host; self.port = port } enum CodingKeys: String, CodingKey { case host, port } public init(from d: Decoder) throws { let c = try d.container(keyedBy: CodingKeys.self) host = try c.decode(String.self, forKey: .host) port = try c.decodeIfPresent(Int.self, forKey: .port) ?? 8060 } } 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). /// Mesh DNS short name for a device (`hostname` → `hostname.lan` / `hostname.wg`). /// The local player defaults to this Mac's hostname when unset. public enum DeviceHostname { public static let defaultSSHUser = "lilith" /// Short hostname of this machine (`fennel` from `fennel.local`). public static func systemShortName() -> String { #if canImport(AppKit) if let n = Host.current().localizedName, !n.isEmpty { return n.lowercased() } #endif let raw = ProcessInfo.processInfo.hostName if raw.hasSuffix(".local") { return String(raw.dropLast(".local".count)).lowercased() } if let dot = raw.firstIndex(of: ".") { return String(raw[.. String { let env = ProcessInfo.processInfo.environment["TV_ANARCHY_SSH_USER"] ?? "" return env.isEmpty ? defaultSSHUser : env } /// LAN-first SSH destinations for a mesh-named host. public static func sshEndpoints(host: String, user: String = sshUser()) -> [String] { ["\(user)@\(host).lan", "\(user)@\(host).wg"] } /// Stable id for a local player on this Mac (`-vlc`, etc.). public static func localPlayerId(kind: HostKind) -> String { let host = systemShortName() switch kind { case .vlc: return "\(host)-vlc" case .quicktime: return "\(host)-quicktime" default: return host } } /// Display name for a local player on this Mac. public static func localPlayerName(kind: HostKind) -> String { let host = systemShortName() switch kind { case .vlc: return "\(host) VLC" case .quicktime: return "QuickTime" default: return host } } /// Optional storage-server mesh hostname from `TV_ANARCHY_STORAGE_HOST`. public static func storageHostFromEnvironment() -> String? { let env = ProcessInfo.processInfo.environment["TV_ANARCHY_STORAGE_HOST"] ?? "" let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed.lowercased() } /// Optional explicit SSH destination for storage-side helpers. public static func storageSSHHostFromEnvironment() -> String? { for key in ["STORAGE_SSH_HOST", "BLACK_SSH_HOST"] { let env = ProcessInfo.processInfo.environment[key] ?? "" let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } return nil } } public struct DeviceConfig: Codable, Sendable, Identifiable, Equatable { public var id: String public var name: String /// Mesh DNS short name. When nil, local players use this Mac's hostname; /// remote devices fall back to `id` for mesh DNS resolution. public var hostname: 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 roku: RokuConn? public var commands: CommandsConfig? public var offlinePolicy: OfflineCachePolicy? public var streamPolicy: StreamPolicy? public init(id: String, name: String, kind: HostKind, hostname: String? = nil, type: DeviceType? = nil, services: DeviceServices? = nil, vlc: VLCConn? = nil, ssh: SSHConn? = nil, mpv: MpvConn? = nil, roku: RokuConn? = nil, commands: CommandsConfig? = nil, offlinePolicy: OfflineCachePolicy? = nil, streamPolicy: StreamPolicy? = nil) { self.id = id; self.name = name; self.hostname = hostname; 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.roku = roku self.commands = commands; self.offlinePolicy = offlinePolicy self.streamPolicy = streamPolicy } enum CodingKeys: String, CodingKey { case id, name, hostname, kind, type, services, vlc, ssh, mpv, roku, commands case offlinePolicy, streamPolicy } /// Tolerant decode: a pre-`type` host config infers its `type` from `kind` and /// takes that type's default `services` — legacy configs infer storage/laptop /// from the player backend 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) hostname = try c.decodeIfPresent(String.self, forKey: .hostname) 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) roku = try c.decodeIfPresent(RokuConn.self, forKey: .roku) commands = try c.decodeIfPresent(CommandsConfig.self, forKey: .commands) offlinePolicy = try c.decodeIfPresent(OfflineCachePolicy.self, forKey: .offlinePolicy) streamPolicy = try c.decodeIfPresent(StreamPolicy.self, forKey: .streamPolicy) } public func resolvedOfflinePolicy() -> OfflineCachePolicy { offlinePolicy ?? .defaults } public func resolvedStreamPolicy() -> StreamPolicy { streamPolicy ?? .defaults } /// Mesh DNS short name — explicit `hostname`, else this Mac for local players, /// else the stable device `id` for remote hosts. public func resolvedHostname() -> String { if let h = hostname?.trimmingCharacters(in: .whitespacesAndNewlines), !h.isEmpty { return h.lowercased() } return kind.isLocal ? DeviceHostname.systemShortName() : id.lowercased() } /// SSH endpoints: configured `mpv`/`ssh` list when non-empty, otherwise /// `user@.lan` then `user@.wg` (LAN before mesh). public func resolvedSSHEndpoints() -> [String] { if let eps = mpv?.endpoints, !eps.isEmpty { return eps } if let eps = ssh?.endpoints, !eps.isEmpty { return eps } return DeviceHostname.sshEndpoints(host: resolvedHostname()) } /// mpv connection with hostname-derived endpoints when the stored list is empty. public func resolvedMpvConn() -> MpvConn? { guard kind == .mpvIPC || kind == .blacktv else { return mpv } let base = mpv ?? MpvConn() return MpvConn(endpoints: resolvedSSHEndpoints(), socket: base.socket, sudo: base.sudo, socat: base.socat, volumeScale: base.volumeScale) } /// VLC HTTP target — local players default to loopback. public func resolvedVlcConn() -> VLCConn? { guard kind == .vlc else { return vlc } return VLCConn(host: vlc?.host ?? "127.0.0.1", port: vlc?.port ?? 8080) } /// Roku ECP target — defaults to `.lan` when host is unset. public func resolvedRokuConn() -> RokuConn? { guard kind == .roku else { return roku } let host = (roku?.host).flatMap { $0.isEmpty ? nil : $0 } ?? "\(resolvedHostname()).lan" return RokuConn(host: host, port: roku?.port ?? 8060) } } 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 — local laptop player (VLC). An optional storage node is added /// when `TV_ANARCHY_STORAGE_HOST` is set in the environment. public static func seeded() -> DevicesConfig { var devices = [ DeviceConfig(id: DeviceHostname.localPlayerId(kind: .vlc), name: DeviceHostname.localPlayerName(kind: .vlc), kind: .vlc, type: .laptop, vlc: VLCConn(host: "127.0.0.1", port: 8080), offlinePolicy: .defaults, streamPolicy: .defaults), ] if let storageHost = DeviceHostname.storageHostFromEnvironment() { devices.append(DeviceConfig( id: storageHost, name: "\(storageHost) TV", kind: .mpvIPC, hostname: storageHost, type: .storage, mpv: MpvConn(), commands: CommandsConfig.blackTVDefaults(bin: "/usr/local/bin/black-tv"))) } return DevicesConfig(devices: devices) } /// The storage server — first device typed as storage, else first `mpv-ipc` host. public var storageDevice: DeviceConfig? { devices.first { $0.type == .storage } ?? devices.first { $0.kind == .mpvIPC } } /// SSH endpoints for storage-side helpers (index, rsync, transmission, etc.). public static func storageSSHEndpoints() -> [String] { if let h = DeviceHostname.storageSSHHostFromEnvironment() { return [h] } if let eps = loadOrSeed().storageDevice?.resolvedSSHEndpoints(), !eps.isEmpty { return eps } if let host = DeviceHostname.storageHostFromEnvironment() { return DeviceHostname.sshEndpoints(host: host) } return [] } /// First storage SSH endpoint (LAN leg when reachable), or empty when unset. public static func storageSSHHost() -> String { storageSSHEndpoints().first ?? "" } /// 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 { var cfg: DevicesConfig if let c = decode(configURL()), !c.devices.isEmpty { cfg = c } else if let legacy = legacyURLs().compactMap({ decode($0) }).first(where: { !$0.devices.isEmpty }) { cfg = legacy try? cfg.save() } else { cfg = seeded() try? cfg.save() } if cfg.migrateOfflinePolicyFromSettings() { try? cfg.save() } if cfg.migrateLegacyIPEndpoints() { try? cfg.save() } return cfg } /// One-time: drop the hardcoded black LAN/WG IPs in favour of hostname-derived /// endpoints (`black.lan` / `black.wg`). Explicit non-legacy overrides are kept. mutating func migrateLegacyIPEndpoints() -> Bool { let legacy = Set(["lilith@10.0.0.11", "lilith@10.9.0.4", "lilith@10.9.0.4", "lilith@10.0.0.11"]) var changed = false for i in devices.indices where devices[i].kind == .mpvIPC { guard let eps = devices[i].mpv?.endpoints, !eps.isEmpty, Set(eps) == legacy else { continue } if devices[i].hostname == nil { devices[i].hostname = devices[i].id } var m = devices[i].mpv ?? MpvConn() m.endpoints = [] devices[i].mpv = m changed = true } return changed } /// The local player device (VLC / QuickTime on this Mac), if configured. public var localDevice: DeviceConfig? { devices.first { $0.kind.isLocal } } /// Preferred playback target for a mode — the first eligible stream host or the /// local offline player. nil when that mode isn't configured. public func preferredDeviceId(for mode: PlaybackMode) -> String? { switch mode { case .stream: return devices.first { !$0.kind.isLocal && $0.kind != .roku && $0.kind != .registry }?.id case .offline: return localDevice?.id } } /// Offline policy for the local player — defaults when absent. public static func localOfflinePolicy() -> OfflineCachePolicy { loadOrSeed().localDevice?.resolvedOfflinePolicy() ?? .defaults } /// Stream buffer policy for the local player — defaults when absent. public static func localStreamPolicy() -> StreamPolicy { loadOrSeed().localDevice?.resolvedStreamPolicy() ?? .defaults } /// One-time lift of offline prefs from `settings.json` onto the local device. mutating func migrateOfflinePolicyFromSettings() -> Bool { guard let i = devices.firstIndex(where: { $0.kind.isLocal }), devices[i].offlinePolicy == nil, let imported = Self.offlinePolicyFromLegacySettings() else { return false } devices[i].offlinePolicy = imported return true } private static func offlinePolicyFromLegacySettings() -> OfflineCachePolicy? { let url = SettingsStore.settingsURL() guard let data = try? Data(contentsOf: url), let raw = try? JSONSerialization.jsonObject(with: data) as? [String: Any], raw["offlineEpisodes"] != nil || raw["offlineWarmupEnabled"] != nil else { return nil } return OfflineCachePolicy( warmupEnabled: raw["offlineWarmupEnabled"] as? Bool ?? true, episodesAhead: raw["offlineEpisodes"] as? Int ?? 3, episodesBehind: raw["offlineEpisodesBehind"] as? Int ?? 0, shows: raw["offlineShows"] as? Int ?? 5, fromContinueWatching: raw["offlineFromContinueWatching"] as? Bool ?? true, cullEnabled: raw["offlineCullEnabled"] as? Bool ?? true, budgetPercent: raw["offlineBudgetPercent"] as? Int ?? 15, cacheDir: nil, pinned: []) } 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: DeviceHostname.localPlayerId(kind: .quicktime), name: DeviceHostname.localPlayerName(kind: .quicktime), kind: .quicktime, type: .laptop) case .vlc: device = DeviceConfig(id: DeviceHostname.localPlayerId(kind: .vlc), name: DeviceHostname.localPlayerName(kind: .vlc), kind: .vlc, type: .laptop, vlc: VLCConn(host: "127.0.0.1", port: 8080)) default: return } let preserved = localDevice?.offlinePolicy if let i = devices.firstIndex(where: { $0.kind == .vlc || $0.kind == .quicktime }) { var d = device if d.offlinePolicy == nil { d.offlinePolicy = preserved } devices[i] = d } else { var d = device if d.offlinePolicy == nil { d.offlinePolicy = preserved } devices.insert(d, 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) } } // MARK: - Broken file markers (governor convention) extension DevicesConfig { /// Touch a sibling `.broken` marker next to a storage-side media path. /// The governor (watch/keeper/check/scan + rsync lists) will skip the file at every step. /// Idempotent and safe. Primary use: when a file won't play in VLC (corrupt decode, frozen time, bad container). /// Returns (succeeded, human message). public static func markStorageFileBroken(_ storagePath: String) async -> (succeeded: Bool, message: String) { let eps = Self.storageSSHEndpoints() guard !eps.isEmpty else { return (false, "no storage SSH endpoints (configure a storage device or TV_ANARCHY_STORAGE_HOST)") } let transport = SSHTransport(endpoints: eps) let marker = storagePath + ".broken" let cmd = "touch \(SSHTransport.shq(marker))" let res = await transport.runRemote(cmd) if res.ok { return (true, "Marked broken: \((marker as NSString).lastPathComponent). Governor will skip it.") } else { let detail = res.stderr.trimmingCharacters(in: .whitespacesAndNewlines) return (false, detail.isEmpty ? "SSH failed on all storage endpoints" : detail) } } /// Remove the .broken marker for a path (re-enable the file for keeper/watch). public static func unmarkStorageFileBroken(_ storagePath: String) async -> (succeeded: Bool, message: String) { let eps = Self.storageSSHEndpoints() guard !eps.isEmpty else { return (false, "no storage SSH endpoints") } let transport = SSHTransport(endpoints: eps) let marker = storagePath + ".broken" let cmd = "rm -f \(SSHTransport.shq(marker)) && echo removed || echo 'no marker or failed'" let res = await transport.runRemote(cmd) if res.ok { return (true, "Unmarked broken for \((marker as NSString).lastPathComponent)") } else { return (false, res.stderr) } } }