tv-anarchy/Sources/TVAnarchyCore/HostConfig.swift
Natalie 92b38b1bae refactor(tv-anarchy): rename PlumTV→TVAnarchy and land session work
Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename
the auto-commit service couldn't stage — it git-add'd the old, now-gone
paths and aborted every cycle), and commits the accumulated work:

- Library: black-built index fast path (LibraryIndex + scanFromIndex) with
  NFS-walk fallback; incremental --add on download-complete; mtime staleness
  gate; loose-file series-collapse fix; determinate scan/index progress.
- Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year).
- Player: sleep timer (timed + end-of-episode); visibility-gated polling.
- Home: Continue Watching cover art + live refresh; Recently Added; adult gate.
- Logs: multi-line selection + copy; truncated giant tx-list errors.
- Hover previews (opt-in) via black ffmpeg + scp.

Also gitignores foreign project trees (governor/mcp/fleet/recommender) that
sit in this directory but belong to their own repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:04:22 -07:00

199 lines
8.7 KiB
Swift

import Foundation
/// What kind of backend a host speaks.
public enum HostKind: String, Codable, Sendable, CaseIterable, Identifiable {
case vlc // VLC HTTP/Lua interface
case blacktv // the legacy `black-tv` verb script over SSH (being retired)
case mpvIPC = "mpv-ipc" // generic mpv JSON IPC over SSH + delegated commands
case quicktime // local QuickTime Player driven by AppleScript (zero-install)
public var id: String { rawValue }
/// Kinds offered in the editor (blacktv is legacy/auto-migrated, hidden).
public static var editable: [HostKind] { [.mpvIPC, .vlc, .quicktime] }
public var label: String {
switch self {
case .vlc: "VLC (HTTP)"
case .blacktv: "black-tv (legacy)"
case .mpvIPC: "mpv over SSH"
case .quicktime: "QuickTime (local)"
}
}
}
/// Connection to a generic mpv host: its JSON IPC socket reached over SSH, plus
/// how to read it (root-owned sockets need `sudo socat`). `volumeScale` is the
/// slider max (mpv's volume is already a percentage; default mirrors mpv's
/// `--volume-max` of 130).
public struct MpvConn: Codable, Sendable, Equatable {
public var endpoints: [String]
public var socket: String
public var sudo: Bool
public var socat: String
public var volumeScale: Int
public init(endpoints: [String], socket: String = "/tmp/mpv.sock",
sudo: Bool = true, socat: String = "socat", volumeScale: Int = 130) {
self.endpoints = endpoints; self.socket = socket
self.sudo = sudo; self.socat = socat; self.volumeScale = volumeScale
}
// Decode with defaults so a minimal `{ "endpoints": [...] }` is valid.
enum CodingKeys: String, CodingKey { case endpoints, socket, sudo, socat, volumeScale }
public init(from d: Decoder) throws {
let c = try d.container(keyedBy: CodingKeys.self)
endpoints = try c.decode([String].self, forKey: .endpoints)
socket = try c.decodeIfPresent(String.self, forKey: .socket) ?? "/tmp/mpv.sock"
sudo = try c.decodeIfPresent(Bool.self, forKey: .sudo) ?? true
socat = try c.decodeIfPresent(String.self, forKey: .socat) ?? "socat"
volumeScale = try c.decodeIfPresent(Int.self, forKey: .volumeScale) ?? 130
}
}
/// Per-host command templates (argv arrays) for the operations a generic mpv
/// host can't do over IPC: launch/library/stats/teardown. A nil template means
/// the host lacks that capability. Tokens: `{query}`, `{season?}`, `{episode?}`,
/// `{path}`, `{releaseId}` (see CommandTemplate).
public struct CommandsConfig: Codable, Sendable, Equatable {
public var launchShow: [String]?
public var launchResume: [String]?
public var launchFile: [String]?
public var releases: [String]?
public var resolveRelease: [String]?
public var stats: [String]?
public var stop: [String]?
public init(launchShow: [String]? = nil, launchResume: [String]? = nil,
launchFile: [String]? = nil, releases: [String]? = nil,
resolveRelease: [String]? = nil, stats: [String]? = nil, stop: [String]? = nil) {
self.launchShow = launchShow; self.launchResume = launchResume
self.launchFile = launchFile; self.releases = releases
self.resolveRelease = resolveRelease; self.stats = stats; self.stop = stop
}
/// The delegated commands for a `black-tv` helper at `bin` the seed default
/// and the legacy-config migration target.
public static func blackTVDefaults(bin: String) -> CommandsConfig {
CommandsConfig(
launchShow: [bin, "play-show", "{query}", "{season?}", "{episode?}"],
launchResume: [bin, "resume-show", "{query}"],
launchFile: [bin, "play", "{path}"],
releases: [bin, "releases"],
resolveRelease: [bin, "resolve-release", "{releaseId}"],
stats: [bin, "stats"],
stop: [bin, "stop"])
}
}
public struct VLCConn: Codable, Sendable, Equatable {
public var host: String
public var port: Int
public init(host: String, port: Int) { self.host = host; self.port = port }
}
public struct SSHConn: Codable, Sendable, Equatable {
/// Ordered endpoints to try (e.g. LAN first, overlay fallback). The working
/// one is pinned at runtime; we only re-probe the others on failure.
public var endpoints: [String]
public var bin: String
public init(endpoints: [String], bin: String) { self.endpoints = endpoints; self.bin = bin }
}
/// One configurable playback host. Password for `vlc` is NOT stored here it's
/// resolved from the portable-net-tv config at runtime (see VLCConfig).
public struct HostConfig: Codable, Sendable, Identifiable, Equatable {
public var id: String
public var name: String
public var kind: HostKind
public var vlc: VLCConn?
public var ssh: SSHConn? // legacy blacktv
public var mpv: MpvConn? // mpv-ipc
public var commands: CommandsConfig?
public init(id: String, name: String, kind: HostKind, vlc: VLCConn? = nil,
ssh: SSHConn? = nil, mpv: MpvConn? = nil, commands: CommandsConfig? = nil) {
self.id = id; self.name = name; self.kind = kind
self.vlc = vlc; self.ssh = ssh; self.mpv = mpv; self.commands = commands
}
}
public struct HostsConfig: Codable, Sendable {
public var hosts: [HostConfig]
public init(hosts: [HostConfig]) { self.hosts = hosts }
public static func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/tv-anarchy/hosts.json")
}
/// Pre-rename location, read once to migrate an existing config forward.
static func legacyConfigURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/plumtv/hosts.json")
}
/// Default seed the two real player targets on this network. black uses
/// generic mpv-IPC for control and delegates launch/library/stats/teardown
/// to its `black-tv` helper script. It tries its LAN address first, then the
/// WG overlay (LAN flaps).
public static func seeded() -> HostsConfig {
HostsConfig(hosts: [
HostConfig(id: "plum-vlc", name: "Plum VLC", kind: .vlc,
vlc: VLCConn(host: "127.0.0.1", port: 8080)),
HostConfig(id: "black", name: "Black TV", kind: .mpvIPC,
mpv: MpvConn(endpoints: ["lilith@10.0.0.11", "lilith@10.9.0.4"]),
commands: CommandsConfig.blackTVDefaults(bin: "/usr/local/bin/black-tv")),
])
}
/// Load `~/.config/tv-anarchy/hosts.json`; migrate the pre-rename
/// `~/.config/plumtv/hosts.json` forward if present; else seed.
public static func loadOrSeed() -> HostsConfig {
if let cfg = decode(configURL()), !cfg.hosts.isEmpty { return cfg }
if let legacy = decode(legacyConfigURL()), !legacy.hosts.isEmpty {
try? legacy.save() // migrate to the new path
return legacy
}
let seed = seeded()
try? seed.save()
return seed
}
private static func decode(_ url: URL) -> HostsConfig? {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(HostsConfig.self, from: data)
}
/// The local player kind currently configured (vlc/quicktime), if any.
public var localPlayerKind: HostKind? {
hosts.first { $0.kind == .vlc || $0.kind == .quicktime }?.kind
}
/// Swap the local player host to `kind`, preserving position. Only local kinds
/// (vlc/quicktime) are meaningful here; anything else is ignored.
public mutating func setLocalPlayer(_ kind: HostKind) {
let host: HostConfig
switch kind {
case .quicktime:
host = HostConfig(id: "local-quicktime", name: "QuickTime", kind: .quicktime)
case .vlc:
host = HostConfig(id: "plum-vlc", name: "Plum VLC", kind: .vlc,
vlc: VLCConn(host: "127.0.0.1", port: 8080))
default:
return
}
if let i = hosts.firstIndex(where: { $0.kind == .vlc || $0.kind == .quicktime }) {
hosts[i] = host
} else {
hosts.insert(host, at: 0)
}
}
public func save() throws {
let url = HostsConfig.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)
}
}