tv-anarchy/Sources/TVAnarchyCore/DeviceConfig.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:12:41 -04:00

780 lines
37 KiB
Swift

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
/// `[<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 {
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[..<dot]).lowercased() }
return raw.lowercased()
}
public static func sshUser() -> 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 (`<hostname>-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@<hostname>.lan` then `user@<hostname>.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 `<hostname>.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 `<file>.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)
}
}
}