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>
780 lines
37 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|